Compare commits
106
Commits
9b6b0b1204
..
main
No files matched your search
@@ -15,13 +15,14 @@ the whole account. There is also a leaderboard (Leaderboard section: top miners
|
||||
banned players left out).
|
||||
|
||||
## How the parts fit
|
||||
- `server/`: the Go server (game rules, Postgres, admin pages) and the web client, which it
|
||||
renders as HTML (htmx, three.js moon). The server is the only authority on game state.
|
||||
Details: `server/AGENTS.md`.
|
||||
- `server/`: the Go server: game rules, Postgres, the JSON API and the admin pages
|
||||
(`/utils`, server-rendered with htmx, behind `ADMIN_PASSWORD`). The server is the only
|
||||
authority on game state. Details: `server/AGENTS.md`.
|
||||
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's
|
||||
JSON API (`/api/state`, `/api/act/<action>`, documented in `server/api.md`) and shows
|
||||
what it sends; its moon generator is a port of `server/web/static/js/terrain.js`.
|
||||
Details: `client/AGENTS.md`.
|
||||
what it sends. It is the only game client, and its moon generator
|
||||
(`client/modules/moon/terrain.lua`, native `mmterrain`) is the only one. Details:
|
||||
`client/AGENTS.md`.
|
||||
- Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
|
||||
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
|
||||
|
||||
@@ -35,6 +36,11 @@ banned players left out).
|
||||
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
|
||||
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
|
||||
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
|
||||
- A worktree's `.env` names its own database (`DB_DATABASE=mm_<task>`), so `migrate`,
|
||||
tests and servers run there reach only `mm_<task>`. Never point anything at the main
|
||||
database (`moonminers`): it holds the author's game.
|
||||
- Never use `git stash` in a worktree: the stash list is shared by every worktree, so a
|
||||
pop can take another agent's work. Commit on your branch, or save a patch file.
|
||||
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
||||
commit it.
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
|
||||
|
||||
- `server/`: Go server, JSON API (`server/api.md`) and the web client (htmx + three.js).
|
||||
- `server/`: Go server: the JSON API (`server/api.md`) and the admin pages (`/utils`).
|
||||
- `client/`: Defold client, for desktop and the browser (WebAssembly).
|
||||
|
||||
## Server and web client
|
||||
## Server
|
||||
|
||||
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
||||
`server/README.md`).
|
||||
@@ -14,25 +14,32 @@ Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
||||
podman-compose up -d db # or: docker compose up -d db
|
||||
cd server
|
||||
make migrate
|
||||
make dev # http://localhost:3000
|
||||
make dev # http://localhost:3000: the game (once built, below); /utils: the admin
|
||||
```
|
||||
|
||||
After pulling changes, run `make migrate` again before starting the server: it applies new
|
||||
migrations and syncs the tools with `config/game.json` (the server alone doesn't).
|
||||
After pulling changes or editing `server/config/game.json`, run `make migrate` again before
|
||||
starting the server: it applies new migrations and syncs the tools and prices with the
|
||||
config (the server alone doesn't).
|
||||
|
||||
## Defold client
|
||||
|
||||
Needs Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor
|
||||
is optional: https://defold.com/download/
|
||||
|
||||
With the server running:
|
||||
Needs the Defold editor (https://defold.com/download/) or Java 21+. The Makefile downloads
|
||||
Defold's command-line builder (`bob.jar`) and runs it with the Java the editor ships, found
|
||||
in the usual places on Linux, macOS and Windows (`make DEFOLD=<folder holding its packages>`
|
||||
for another place, `JAVA=<path>` for your own Java); without an editor, `java` from PATH.
|
||||
|
||||
```bash
|
||||
cd client
|
||||
make run # desktop build, plays against http://localhost:3000
|
||||
make web && make serve-web # browser build: open http://localhost:8080
|
||||
make web # browser build into dist/web/MoonMiners/
|
||||
```
|
||||
|
||||
The server serves the browser build at http://localhost:3000 (from
|
||||
`client/dist/web/MoonMiners`, or `MM_WEB_DIR`). It looks for the build when it starts:
|
||||
after the first `make web`, restart the server; later builds only need a page reload.
|
||||
Without a build, `/` goes to the admin. The desktop and browser games keep separate saved
|
||||
players.
|
||||
|
||||
## Deploy
|
||||
|
||||
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
|
||||
+16
-10
@@ -1,8 +1,8 @@
|
||||
# AGENTS.md — Moon Miners client (Defold)
|
||||
|
||||
A native client for Moon Miners (rules and state live in the Go server: `../server`,
|
||||
`../server/AGENTS.md`; the repo: `../AGENTS.md`). Same layout and HUD look as the web build
|
||||
(header, moon list left, the section in the middle, aside with the moon viewport and the
|
||||
`../server/AGENTS.md`; the repo: `../AGENTS.md`), and the only game client. Layout and HUD
|
||||
look come from the old web client (header, moon list left, the section in the middle, aside with the moon viewport and the
|
||||
selected well; on Moons the aside only shows, every action is on the well cards), made game-native. Target 16:10 at 1280x800 (Steam Deck); bigger windows
|
||||
show the UI at native pixel size with more room, smaller ones rearrange. Mouse now,
|
||||
gamepad later. Details live in each file's header comment; this file has the rules and a
|
||||
@@ -42,12 +42,16 @@ map.
|
||||
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
|
||||
layout is top-left based (x right, y down).
|
||||
- Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua;
|
||||
`ASIDE_KEYS`, header and moon list sigs likewise) or it won't update. What must survive
|
||||
`ASIDE_KEYS` and the header's sig likewise; the moon list's rows: `moonlist.rows_sig`;
|
||||
the moon's facts in the aside: `aside.facts_sig`; the Moons parts: their models in
|
||||
moons.lua) or it won't update. The shown state is frozen (`blocks.freeze`: `blocks.sig`
|
||||
writes each part once per state, by content): never change `g.state` in place; a
|
||||
prediction changes its copy before it is shown. What must survive
|
||||
a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across
|
||||
builds outside its block. A new per-build registry notes its entries in the block journal
|
||||
(`blocks.note`/`push`/`put`); code that builds outside a build runs through
|
||||
`blocks.bound(fn)`.
|
||||
- Theme: every color is a token (`theme.color`, dark and light from app.css): no hex or
|
||||
- Theme: every color is a token (`theme.color`, dark and light, from the old web client's CSS): no hex or
|
||||
vector colors in code (templates via `theme.template`, config colors via `theme.item`,
|
||||
glows via `theme.glow_blend()`). The moon viewport stays dark. Sizes from `theme.size`.
|
||||
- Text: styles from `theme.text`; `₥`, `★` and other symbols through the `sym` font
|
||||
@@ -56,7 +60,7 @@ map.
|
||||
- Node budget: `max_nodes: 4096` in `game.gui`; text past the font batch limit silently
|
||||
doesn't render (make check catches the warning); stencils and images split batches.
|
||||
Outline textures use slice9 2.
|
||||
- Motion: easing curves match the web (`modules/anim.lua`). GUI nodes don't pass alpha to
|
||||
- Motion: easing curves match the old web client (`modules/anim.lua`). GUI nodes don't pass alpha to
|
||||
children: fade content in from under a veil, not by fading a subtree. Every new
|
||||
animation honors reduced motion (`anim.reduced`). Effects for action results go in
|
||||
`modules/fx.lua`; numbers that change ease (`H.tick_num`); the wallet counts through fx.
|
||||
@@ -140,7 +144,8 @@ an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
|
||||
- `core/keys.lua`: keyboard (focus, Enter/Space, scrolling keys, fields, F11, Ctrl +/-/0).
|
||||
- `core/ready.lua`: ready counts on our clock. `core/ticks.lua`: countdowns, bars, eased numbers.
|
||||
- Regions: `banner.lua`, `header.lua`, `moonlist.lua`, `aside.lua`, `settings.lua` (gear
|
||||
menu), `savecode.lua`, `grant.lua` (admin grant dialog).
|
||||
menu), `savecode.lua`, `grant.lua` (admin grant dialog), `admin.lua` (admin area from the gear
|
||||
menu, instead of the page; screens: `moonlab.lua`, the moon settings editor).
|
||||
- Sections: `moons.lua`, `market.lua` (+ `ticker.lua`, `chart.lua`, `sale.lua`,
|
||||
`leaderboard.lua`), `refinery.lua`, `equipment.lua`, `store.lua`, `advisors.lua`,
|
||||
`help.lua`, `moon_sale.lua`, `reveal.lua` (new-moon teaser and reveal window), `cards.lua`
|
||||
@@ -149,7 +154,7 @@ an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
|
||||
|
||||
Components (`components/`, API in each header; build new UI from these): `button`,
|
||||
`dialog`, `panel`, `card` (+ `card.grid`), `tabs`, `list` (long lists, Druid Data List),
|
||||
`scroll`, `bar`, `chip`, `stat`, `row`. `widgets/moon_row.lua`: the moon list row.
|
||||
`scroll`, `bar`, `chip`, `stat`, `row`, `slider`. `widgets/moon_row.lua`: the moon list row.
|
||||
|
||||
Modules (`modules/`):
|
||||
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
|
||||
@@ -163,14 +168,15 @@ Modules (`modules/`):
|
||||
`settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
|
||||
- `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
|
||||
`hot.lua` (hot reload).
|
||||
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, terrain.js settings port).
|
||||
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, the moon settings and
|
||||
their `DEFAULTS`, the look the server's `moondesc.go` mirrors).
|
||||
|
||||
Elsewhere:
|
||||
- `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
|
||||
- `moon/`: moon collection, `moon.script`, materials (port of scene.js).
|
||||
`render/mm.render_script`: HDR moon pass with bloom.
|
||||
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator
|
||||
(C port of terrain.js; `scripts/moon-check.mjs` compares, in `make check`).
|
||||
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator, the
|
||||
game's only one (it began as a C port of the web client's terrain.js, now deleted).
|
||||
- `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
|
||||
`ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
|
||||
- `web/index.html`: the web build's page.
|
||||
|
||||
+28
-13
@@ -1,5 +1,17 @@
|
||||
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool,
|
||||
# python3 and luajit for shot, play, drive, dev and check.
|
||||
# Moon Miners Defold client. Builds with bob.jar, run by the Java the Defold editor ships
|
||||
# (its packages/jdk-*), so the editor is all a build needs. The editor is looked for where it
|
||||
# usually is on Linux (~/Defold, /opt/Defold), macOS (Defold.app in /Applications or
|
||||
# ~/Applications) and Windows (~/Defold, C:/Defold, Git Bash or MSYS make); DEFOLD=<the folder
|
||||
# holding its packages> for another place, JAVA=<path> for a Java of your own. With no editor
|
||||
# found it is java (21+) from PATH. xvfb-run, Xvfb, ImageMagick, xdotool, python3 and luajit
|
||||
# for shot, play, drive, dev and check (Linux).
|
||||
DEFOLD_DIRS := $(DEFOLD) $(HOME)/Defold /opt/Defold \
|
||||
/Applications/Defold.app/Contents/Resources $(HOME)/Applications/Defold.app/Contents/Resources \
|
||||
$(USERPROFILE)/Defold C:/Defold
|
||||
# (make's own wildcard and sort, no shell tools; the last after sorting is the newest)
|
||||
DEFOLD_JAVA := $(lastword $(sort $(foreach d,$(DEFOLD_DIRS),$(wildcard \
|
||||
$(d)/packages/jdk*/bin/java $(d)/packages/jdk*/bin/java.exe $(d)/packages/jdk*/Contents/Home/bin/java))))
|
||||
JAVA ?= $(or $(DEFOLD_JAVA),java)
|
||||
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
|
||||
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
|
||||
BOB := tools/bob.jar
|
||||
@@ -18,25 +30,26 @@ $(BOB):
|
||||
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
|
||||
|
||||
resolve: $(BOB)
|
||||
java -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
|
||||
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
|
||||
.internal/lib/dependencies.json: game.project | $(BOB)
|
||||
java -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
touch $@
|
||||
|
||||
build: $(BOB) .internal/lib/dependencies.json
|
||||
java -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
|
||||
$(JAVA) -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
|
||||
--archive --bundle-output dist/$(PLATFORM) build bundle
|
||||
|
||||
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release
|
||||
# by default; WEB_VARIANT=debug keeps logs and the test hook (window.mmTestQueue).
|
||||
# `make serve-web` serves it on :8080 with /api and /static proxied to UPSTREAM.
|
||||
# The game server serves it at / (server.go, MM_WEB_DIR); `make serve-web` serves it on :8080
|
||||
# with /api and /static proxied to UPSTREAM (a test server).
|
||||
WEB_VARIANT ?= release
|
||||
UPSTREAM ?= http://localhost:3000
|
||||
WEB_PORT ?= 8080
|
||||
web: $(BOB) .internal/lib/dependencies.json
|
||||
java -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
|
||||
$(JAVA) -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
|
||||
--archive --bundle-output dist/web build bundle
|
||||
|
||||
serve-web:
|
||||
@@ -50,7 +63,7 @@ dev: build
|
||||
|
||||
reload: $(BOB)
|
||||
@mkdir -p dist/dev
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|
||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
|
||||
@scripts/dev.py reload
|
||||
|
||||
@@ -63,7 +76,7 @@ SCENARIO ?= smoke
|
||||
DRIVE ?= --fixture
|
||||
drive: $(BOB) .internal/lib/dependencies.json
|
||||
@mkdir -p dist
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
||||
scripts/drive.py $(DRIVE) $(SCENARIO)
|
||||
|
||||
@@ -71,7 +84,7 @@ drive: $(BOB) .internal/lib/dependencies.json
|
||||
# dist/drive/lint-<size>/. Needs MM_API (a test server).
|
||||
lint: $(BOB) .internal/lib/dependencies.json
|
||||
@mkdir -p dist
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
||||
@for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done
|
||||
|
||||
@@ -79,7 +92,7 @@ lint: $(BOB) .internal/lib/dependencies.json
|
||||
# scenario run (sections) against MM_API (required: a test server) if it answers.
|
||||
check: $(BOB) .internal/lib/dependencies.json
|
||||
@mkdir -p dist
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|
||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
|
||||
scripts/check.py
|
||||
|
||||
@@ -90,11 +103,13 @@ run: build
|
||||
|
||||
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
|
||||
shot: build
|
||||
scripts/shot.sh $(BIN) $(SHOT) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
||||
scripts/shot.sh $(BIN) $(SHOT) $(if $(MM_API),--config=mm.api_base=$(MM_API)) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
||||
|
||||
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
|
||||
# Needs MM_API (a test server): it makes a new player there.
|
||||
play: build
|
||||
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
|
||||
@test -n "$(MM_API)" || { echo "make play: set MM_API to a test server (no default)"; exit 1; }
|
||||
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.api_base=$(MM_API) --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
|
||||
|
||||
clean:
|
||||
rm -rf build dist .internal/cache
|
||||
@@ -6,9 +6,12 @@
|
||||
-- local l = list.new(g, parent, x, y, w, h, {
|
||||
-- key = "moons", row_h = 64, -- the row's slot (gap included)
|
||||
-- create = function(item, index) ... return root[, druid component] end,
|
||||
-- focus_id = function(item) return "moon:" .. item.id end }) -- (optional) the test
|
||||
-- focus_id = function(item) return "moon:" .. item.id end, -- (optional) the test
|
||||
-- -- id its row registers: Tab walks
|
||||
-- -- every row, scrolling (modules/focus)
|
||||
-- on_remove = function(node, item) ... end }) -- (optional) a row scrolled out, before
|
||||
-- -- its node is deleted (not when the
|
||||
-- -- list itself goes in a rebuild)
|
||||
-- l:set_data(items) -- (again with a filtered list: the rows are made again)
|
||||
--
|
||||
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
|
||||
@@ -43,6 +46,13 @@ function M.new(g, parent, x, y, w, h, opts)
|
||||
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
|
||||
return l.create(item, index)
|
||||
end)
|
||||
if opts.on_remove then
|
||||
l.data.on_element_remove:subscribe(function(_, _, node, _, item)
|
||||
if not g.tearing_down then
|
||||
opts.on_remove(node, item)
|
||||
end
|
||||
end)
|
||||
end
|
||||
l.scroll.on_scroll:subscribe(function(_, pos)
|
||||
if g.tearing_down then
|
||||
return -- (the old list emptying itself during a rebuild: keep the offset)
|
||||
@@ -55,15 +65,16 @@ function M.new(g, parent, x, y, w, h, opts)
|
||||
end
|
||||
|
||||
function M:set_data(items)
|
||||
local saved = self.g.scroll_pos[self.key] or 0 -- (setting the data scrolls to the top)
|
||||
local saved = self.g.scroll_pos[self.key] or 0
|
||||
self.items = items
|
||||
self.data:set_data(items)
|
||||
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the
|
||||
-- scroll event).
|
||||
-- At the saved offset first (Druid clamps it to the rows), so the data list makes the
|
||||
-- rows there, not the top ones only to drop them again.
|
||||
local s = self.scroll
|
||||
s:set_size(self.grid:get_size_for(#items))
|
||||
s.target_position.y = saved
|
||||
s:_set_scroll_position(s.position.x, saved)
|
||||
s.target_position.y = s.position.y
|
||||
self.data:set_data(items)
|
||||
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
|
||||
for _, a in ipairs(self.g.scroll_areas) do
|
||||
if a.list == self then
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Slider (the web's input type=range): a thin track, its filled part and a knob. Press
|
||||
-- anywhere on the track's row to jump there, drag to move; the value follows the pointer
|
||||
-- exactly (screen position mapped into the track, not summed deltas).
|
||||
--
|
||||
-- local s = slider.new(g, parent, x, y, w, h, { min, max, step, value, id, zone,
|
||||
-- on_change = fn(v), on_release = fn(v) })
|
||||
-- s:set(v) -- move it without on_change (another control changed the value)
|
||||
--
|
||||
-- Built on Druid's slider (input priority high, so a scroll area under it doesn't take
|
||||
-- the drag); its input is replaced by the pointer mapping above. zone: a node the press
|
||||
-- must be inside (a scroll area's view: hidden rows can't be grabbed). on_change gets
|
||||
-- the value snapped to step; on_release when the drag ends. Test id: opts.id (kind
|
||||
-- "slider", no keyboard focus: the number field beside it is the keyboard's way in).
|
||||
|
||||
local ui = require("modules.ui")
|
||||
local test = require("modules.test")
|
||||
|
||||
local M = {}
|
||||
M.__index = M
|
||||
|
||||
local TOUCH = hash("touch")
|
||||
local KNOB_W, KNOB_H, TRACK_H = 10, 16, 4
|
||||
|
||||
local function vec(x, y)
|
||||
return vmath.vector3(x, y, 0)
|
||||
end
|
||||
|
||||
local function snap(v, o)
|
||||
local n = math.floor((v - o.min) / o.step + 0.5)
|
||||
v = o.min + n * o.step
|
||||
-- (decimal steps: drop float noise like 0.30000000000000004)
|
||||
v = tonumber(string.format("%.6f", v))
|
||||
return math.max(o.min, math.min(o.max, v))
|
||||
end
|
||||
|
||||
function M.new(g, parent, x, y, w, h, opts)
|
||||
local self = setmetatable({ opts = opts, w = w }, M)
|
||||
local hit = ui.rect(parent, x, y, w, h, "none")
|
||||
ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), w, TRACK_H, "line_hi")
|
||||
self.fill = ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), 0, TRACK_H, "cyan", 0.7)
|
||||
local knob = ui.rect(hit, 0, 0, KNOB_W, KNOB_H, "cyan")
|
||||
gui.set_pivot(knob, gui.PIVOT_CENTER)
|
||||
gui.set_position(knob, vec(0, -h / 2))
|
||||
self.knob = knob
|
||||
local s = g.druid:new_slider(knob, vec(w, -h / 2), function(_, f)
|
||||
local v = snap(opts.min + f * (opts.max - opts.min), opts)
|
||||
self:_fill(f)
|
||||
if v ~= self.value then
|
||||
self.value = v
|
||||
if opts.on_change then
|
||||
opts.on_change(v)
|
||||
end
|
||||
end
|
||||
end)
|
||||
self.s = s
|
||||
local zone = opts.zone
|
||||
s.on_input = function(sl, action_id, action)
|
||||
if action_id ~= TOUCH or not action.screen_x then
|
||||
return false
|
||||
end
|
||||
if not sl.is_drag then
|
||||
if not (action.pressed and gui.pick_node(hit, action.x, action.y)
|
||||
and (not zone or gui.pick_node(zone, action.x, action.y))) then
|
||||
return false
|
||||
end
|
||||
sl.is_drag = true
|
||||
end
|
||||
local p = gui.screen_to_local(knob, vec(action.screen_x, action.screen_y))
|
||||
local f = math.max(0, math.min(1, p.x / w))
|
||||
if f ~= sl.value then
|
||||
sl:set(f)
|
||||
end
|
||||
if action.released then
|
||||
sl.is_drag = false
|
||||
if opts.on_release then
|
||||
opts.on_release(self.value)
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
self:set(opts.value)
|
||||
test.register(opts.id, hit, "slider", opts.label, zone, true)
|
||||
return self
|
||||
end
|
||||
|
||||
function M:_fill(f)
|
||||
gui.set_size(self.fill, vec(math.floor(self.w * f + 0.5), TRACK_H))
|
||||
end
|
||||
|
||||
function M:set(v)
|
||||
local o = self.opts
|
||||
v = math.max(o.min, math.min(o.max, tonumber(v) or o.min))
|
||||
self.value = v
|
||||
local f = o.max > o.min and (v - o.min) / (o.max - o.min) or 0
|
||||
self.s:set(f, true)
|
||||
self:_fill(f)
|
||||
end
|
||||
|
||||
return M
|
||||
+25
-5
@@ -160,6 +160,7 @@ end
|
||||
|
||||
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
|
||||
local running = {}
|
||||
local shaking = {} -- { node, t, rest = { p } } of the shakes running (M.shake)
|
||||
|
||||
local function now()
|
||||
return socket.gettime()
|
||||
@@ -208,6 +209,7 @@ end
|
||||
-- Drop every frame-driven tween (a screen going away).
|
||||
function M.clear()
|
||||
running = {}
|
||||
shaking = {}
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- fading content
|
||||
@@ -343,21 +345,39 @@ end
|
||||
|
||||
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
|
||||
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
|
||||
-- A shake that starts while another runs on the node replaces it and keeps its resting
|
||||
-- place (read mid-shake, the position is off by up to amp and would stay so).
|
||||
local SHAKE = { { 0, 0 }, { 0.2, -1 }, { 0.4, 5 / 6 }, { 0.6, -0.5 }, { 0.8, 1 / 3 }, { 1, 0 } }
|
||||
function M.shake(node, amp, dur, delay)
|
||||
if M.reduced then
|
||||
return
|
||||
end
|
||||
amp, dur = amp or 6, dur or 0.4
|
||||
local p
|
||||
return M.tween(node, dur, function(k, n)
|
||||
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay)
|
||||
local rest = {}
|
||||
local keep = {}
|
||||
for _, s in ipairs(shaking) do
|
||||
if not s.t.dead then
|
||||
local ok, same = pcall(function() return s.node == node end)
|
||||
if ok and same then
|
||||
M.cancel(s.t)
|
||||
rest = s.rest
|
||||
else
|
||||
keep[#keep + 1] = s
|
||||
end
|
||||
end
|
||||
end
|
||||
shaking = keep
|
||||
local t = M.tween(node, dur, function(k, n)
|
||||
rest.p = rest.p or gui.get_position(n) -- where it is when the shake starts (after a delay)
|
||||
local p = rest.p
|
||||
gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
|
||||
end, delay or 0, function()
|
||||
if p then
|
||||
pcall(gui.set_position, node, p)
|
||||
if rest.p then
|
||||
pcall(gui.set_position, node, rest.p)
|
||||
end
|
||||
end)
|
||||
shaking[#shaking + 1] = { node = node, t = t, rest = rest }
|
||||
return t
|
||||
end
|
||||
|
||||
-- 1D value noise (smooth), for shakes.
|
||||
|
||||
+13
-1
@@ -161,7 +161,9 @@ local function request(method, path, params, cb)
|
||||
err = "network error"
|
||||
elseif status < 200 or status >= 300 then
|
||||
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
|
||||
elseif data == nil then
|
||||
elseif data == nil and status ~= 204 and method == "POST" then
|
||||
err = "bad JSON"
|
||||
elseif data == nil and method == "GET" and response.response ~= "null" then
|
||||
err = "bad JSON"
|
||||
end
|
||||
done(err, data, status, recv)
|
||||
@@ -185,6 +187,16 @@ function M.get_save_code(cb)
|
||||
request("GET", "/api/save-code", nil, cb)
|
||||
end
|
||||
|
||||
-- The moon look tuned in the moon lab: data is the saved object, or nil when none is saved
|
||||
-- (GET answers null). save posts the whole settings object (admins; 204 on success).
|
||||
function M.get_moon_settings(cb)
|
||||
request("GET", "/api/moon-settings", nil, cb)
|
||||
end
|
||||
|
||||
function M.save_moon_settings(settings, cb)
|
||||
request("POST", "/api/moon-settings", settings, cb)
|
||||
end
|
||||
|
||||
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
|
||||
function M.act(name, params, cb)
|
||||
if reject_name ~= "" and name == reject_name then
|
||||
|
||||
@@ -36,9 +36,50 @@ end
|
||||
|
||||
---------------------------------------------------------------- signatures
|
||||
|
||||
local function ser(v, buf)
|
||||
-- Frozen tables (M.freeze: a state's parts, which nothing changes once shown) are written
|
||||
-- as a token of their content, worked out once per table: two tables with the same
|
||||
-- content get the same token, so a block whose inputs the server's answer left as the
|
||||
-- prediction had them is kept, and a list as long as the moons is serialized once per
|
||||
-- state, not once per block and build. (A frozen table must not change.)
|
||||
local frozen = setmetatable({}, { __mode = "k" }) -- table -> true (not worked out yet) or its token
|
||||
local tokens, token_bytes, token_n = {}, 0, 0 -- content -> token; the content strings' size
|
||||
local TOKEN_BYTES = 4 * 1024 * 1024 -- (then forgotten: equal content gets a new token once)
|
||||
|
||||
local ser
|
||||
|
||||
local function token_of(v)
|
||||
local tok = frozen[v]
|
||||
if tok ~= true then
|
||||
return tok
|
||||
end
|
||||
local buf = {}
|
||||
frozen[v] = false -- (while it's written: a table inside itself is written out)
|
||||
ser(v, buf, true)
|
||||
local s = table.concat(buf)
|
||||
tok = tokens[s]
|
||||
if not tok then
|
||||
if token_bytes > TOKEN_BYTES then
|
||||
tokens, token_bytes = {}, 0
|
||||
end
|
||||
token_n = token_n + 1
|
||||
tok = "#" .. token_n
|
||||
tokens[s], token_bytes = tok, token_bytes + #s
|
||||
end
|
||||
frozen[v] = tok
|
||||
return tok
|
||||
end
|
||||
|
||||
local function key_lt(a, b)
|
||||
return tostring(a) < tostring(b)
|
||||
end
|
||||
|
||||
ser = function(v, buf, own)
|
||||
local t = type(v)
|
||||
if t == "table" then
|
||||
if not own and frozen[v] then
|
||||
buf[#buf + 1] = token_of(v)
|
||||
return
|
||||
end
|
||||
buf[#buf + 1] = "{"
|
||||
local n = #v
|
||||
for i = 1, n do
|
||||
@@ -55,7 +96,7 @@ local function ser(v, buf)
|
||||
end
|
||||
if keys then
|
||||
if mixed then
|
||||
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
|
||||
table.sort(keys, key_lt)
|
||||
else
|
||||
table.sort(keys)
|
||||
end
|
||||
@@ -68,7 +109,9 @@ local function ser(v, buf)
|
||||
end
|
||||
buf[#buf + 1] = "}"
|
||||
elseif t == "string" then
|
||||
buf[#buf + 1] = #v .. ":" .. v
|
||||
buf[#buf + 1] = #v
|
||||
buf[#buf + 1] = ":"
|
||||
buf[#buf + 1] = v
|
||||
elseif t == "function" then
|
||||
buf[#buf + 1] = "f"
|
||||
else
|
||||
@@ -76,7 +119,8 @@ local function ser(v, buf)
|
||||
end
|
||||
end
|
||||
|
||||
-- A string that is the same exactly when the values are (tables by content, keys sorted).
|
||||
-- A string that is the same exactly when the values are (tables by content, keys sorted;
|
||||
-- frozen ones by their token, the same for the same content).
|
||||
function M.sig(...)
|
||||
local buf = {}
|
||||
for i = 1, select("#", ...) do
|
||||
@@ -86,6 +130,23 @@ function M.sig(...)
|
||||
return table.concat(buf)
|
||||
end
|
||||
|
||||
-- Freeze the tables in t (a state: its lists and records, one level down; t itself too)
|
||||
-- for M.sig: nothing may change them afterwards (a prediction changes its copy before).
|
||||
function M.freeze(t)
|
||||
if type(t) ~= "table" then
|
||||
return t
|
||||
end
|
||||
if frozen[t] == nil then
|
||||
frozen[t] = true
|
||||
end
|
||||
for _, v in pairs(t) do
|
||||
if type(v) == "table" and frozen[v] == nil then
|
||||
frozen[v] = true
|
||||
end
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- registrations
|
||||
|
||||
-- Note a registration made while building (fn(...) makes it again in a later build that
|
||||
@@ -216,6 +277,37 @@ local function rezone(b)
|
||||
end
|
||||
end
|
||||
|
||||
-- After a kept block is taken: its Druid components (and those of the blocks inside it)
|
||||
-- get new uids, in their order, as if made now. Druid hands input to the newest first
|
||||
-- (by uid: druid_instance's sort_input_comparator), so a kept button inside a box rebuilt
|
||||
-- around it (a slot in a well card, which selects the well when clicked) still gets its
|
||||
-- clicks first, as a rebuilt one would.
|
||||
local new_uid = require("druid.component").create_uid
|
||||
local function renew(c)
|
||||
local cc = c._component
|
||||
if cc and cc._uid then
|
||||
cc._uid = new_uid()
|
||||
cc._is_input_priority_changed = true -- (the instance sorts its input stack again)
|
||||
end
|
||||
local kids = c.get_childrens and c:get_childrens() or {}
|
||||
for i = 1, #kids do -- (already the whole subtree, in its order)
|
||||
local k = kids[i]._component
|
||||
if k and k._uid then
|
||||
k._uid = new_uid()
|
||||
end
|
||||
end
|
||||
end
|
||||
local function reorder(b)
|
||||
for _, c in ipairs(b.comps) do
|
||||
pcall(renew, c)
|
||||
end
|
||||
for _, e in ipairs(b.journal) do
|
||||
if e.block and not e.block.dead then
|
||||
reorder(e.block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- True while a kept block's registrations are made again (test.register looks for its
|
||||
-- clip zone again then: the one it was given may have gone with the old parent).
|
||||
function M.replaying()
|
||||
@@ -318,6 +410,7 @@ function M.open(g, key, sig, parent)
|
||||
reg.used[key] = true
|
||||
gui.set_parent(old.root, parent, false)
|
||||
rezone(old)
|
||||
reorder(old)
|
||||
adopt(reg, old)
|
||||
return old, true
|
||||
end
|
||||
|
||||
@@ -28,6 +28,16 @@ local M = {}
|
||||
|
||||
local EPS = 0.01
|
||||
|
||||
-- A copy of a caller's opts (the label leaves change their copy: one opts table can serve
|
||||
-- several labels).
|
||||
local function copy(opts)
|
||||
local s = {}
|
||||
for k, v in pairs(opts or {}) do
|
||||
s[k] = v
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
local function differs(a, b)
|
||||
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
|
||||
end
|
||||
@@ -111,7 +121,7 @@ function M.label(parent, str, style, h, opts)
|
||||
if ui.needs_rich(str) and not (type(style) == "table" and style.markup) then -- (symbols: ui.rich runs, a container whose y is the middle)
|
||||
local n, w = ui.rich(parent, str, style, 0, 0)
|
||||
gui.set_pivot(n, gui.PIVOT_W)
|
||||
local s = opts or {}
|
||||
local s = copy(opts)
|
||||
s.fit, s.height = nil, h
|
||||
return { node = n, size = false, style = s, measure = function() return w, h end }
|
||||
end
|
||||
@@ -124,7 +134,7 @@ function M.label(parent, str, style, h, opts)
|
||||
w = w - ui.text_width(n)
|
||||
gui.set_text(n, t)
|
||||
end
|
||||
local s = opts or {}
|
||||
local s = copy(opts)
|
||||
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||
return { node = n, size = false, fit = fit, shrink = shrink, style = s, measure = function() return w, h end }
|
||||
@@ -139,7 +149,7 @@ function M.para(parent, str, style, lh, opts)
|
||||
local one = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), "Ag")
|
||||
local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
|
||||
gui.set_leading(n, lh / line)
|
||||
local s = opts or {}
|
||||
local s = copy(opts)
|
||||
local lines = s.lines
|
||||
s.lines = nil
|
||||
local measure = M.text_measure(n, lh)
|
||||
@@ -223,7 +233,7 @@ function M.made(style, make, kids) return M.box(style, kids, { make = make }) en
|
||||
-- A leaf for a text node that already exists (a template's part): pivot W, its middle at
|
||||
-- the box's middle, h tall, as wide as its text (set the text first).
|
||||
function M.node_label(node, h, opts)
|
||||
local s = opts or {}
|
||||
local s = copy(opts)
|
||||
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||
return { node = node, size = false, fit = fit, shrink = shrink, style = s, measure = function() return ui.text_width(node), h end }
|
||||
|
||||
@@ -24,7 +24,7 @@ local M = {}
|
||||
|
||||
local entries = {} -- { key, node, kind, label, zone, scope }
|
||||
local scope = "page"
|
||||
local seen = {} -- key -> count in this build (duplicate ids get "#n")
|
||||
local seen = {} -- base -> entries with it (duplicate ids get "#n")
|
||||
|
||||
local function alive(node)
|
||||
return pcall(gui.get_id, node)
|
||||
@@ -67,8 +67,30 @@ function M.add(id, node, kind, label, zone)
|
||||
return
|
||||
end
|
||||
local base = scope .. ":" .. (id or ("label:" .. tostring(label)))
|
||||
local key = base
|
||||
if (seen[base] or 0) > 0 then
|
||||
-- A row made again (a long list scrolled back, a search, a kept block replayed)
|
||||
-- leaves its deleted node's entry behind: drop those, so the live row keeps the
|
||||
-- plain key that list_step and find look for.
|
||||
local keep, taken = {}, {}
|
||||
for _, e in ipairs(entries) do
|
||||
if e.base ~= base then
|
||||
keep[#keep + 1] = e
|
||||
elseif alive(e.node) then
|
||||
keep[#keep + 1] = e
|
||||
taken[e.key] = true
|
||||
else
|
||||
seen[base] = seen[base] - 1
|
||||
end
|
||||
end
|
||||
entries = keep
|
||||
local n = 1
|
||||
while taken[key] do
|
||||
n = n + 1
|
||||
key = base .. "#" .. n
|
||||
end
|
||||
end
|
||||
seen[base] = (seen[base] or 0) + 1
|
||||
local key = seen[base] > 1 and (base .. "#" .. seen[base]) or base
|
||||
entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
|
||||
end
|
||||
|
||||
@@ -225,12 +247,16 @@ end
|
||||
|
||||
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
|
||||
-- the item before / after the focused row (or the first / last when coming from outside),
|
||||
-- scrolled into view; the rebuild makes its row, which then has the focus. Returns true
|
||||
-- when it moved the focus.
|
||||
-- scrolled into view; the rebuild makes its row, which then has the focus. Within the rows
|
||||
-- in view Tab goes in order (a row's own buttons too); from a button inside a row it steps
|
||||
-- from that row (the one under it). Returns true when it moved the focus.
|
||||
local function list_step(g, f, cur, e, dir, scope_)
|
||||
local a = list_of(g, cur)
|
||||
local items, i
|
||||
if a then
|
||||
if list_of(g, e) == a then
|
||||
return false -- (the next element is in view in the same list)
|
||||
end
|
||||
items = a.list.items
|
||||
local id = cur.base:sub(#scope_ + 2)
|
||||
for n, item in ipairs(items) do
|
||||
@@ -238,6 +264,11 @@ local function list_step(g, f, cur, e, dir, scope_)
|
||||
i = n + dir
|
||||
end
|
||||
end
|
||||
if not i and a.list.row_h then
|
||||
local r, v = M.rect(cur.node), M.rect(a.view)
|
||||
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
|
||||
i = math.floor((r.y + r.h / 2 - v.y + pos) / a.list.row_h) + 1 + dir
|
||||
end
|
||||
if not i or i < 1 or i > #items then
|
||||
return false -- (leaving the list: the next element on the page)
|
||||
end
|
||||
|
||||
+19
-1
@@ -1015,9 +1015,27 @@ function M:batch_ready(g, jobs)
|
||||
end
|
||||
end
|
||||
|
||||
-- (a text node's position is its pivot point and its size not its text's: the box is the
|
||||
-- text's own, round that point)
|
||||
local PIVOT = { -- (fraction of the box left of / above the pivot point)
|
||||
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
|
||||
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
|
||||
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
|
||||
}
|
||||
|
||||
function M:box_of(key)
|
||||
local b = self.binds[key]
|
||||
return b and self:rect_of(b.box or b.node)
|
||||
if not b then
|
||||
return nil
|
||||
end
|
||||
if b.box or gui.get_type(b.node) ~= gui.TYPE_TEXT then
|
||||
return self:rect_of(b.box or b.node)
|
||||
end
|
||||
local r = self:rect_of(b.node)
|
||||
local k = PIVOT[gui.get_pivot(b.node)] or { 0, 0 }
|
||||
local w = ui.text_width(b.node)
|
||||
local h = r.h * gui.get_scale(b.node).y
|
||||
return { x = r.x - k[1] * w, y = r.y - k[2] * h, w = w, h = h }
|
||||
end
|
||||
|
||||
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
-- (render/mm.render_script) draws the scene into an HDR target and binds it as
|
||||
-- `moon_tex` for the GUI node's material, which tone maps it (ACES, like three.js).
|
||||
--
|
||||
-- Settings: GET /api/moon-settings (the values tuned on /utils/moons), merged over the
|
||||
-- Settings: GET /api/moon-settings (the values tuned in the moon lab), merged over the
|
||||
-- defaults like the web does; until the request answers nothing is generated.
|
||||
--
|
||||
-- Debug config: mm.moon_id (show this id instead), mm.moon_spin=0 (no spin, fixed angle).
|
||||
@@ -38,10 +38,17 @@ local M = {}
|
||||
M.id = nil
|
||||
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
|
||||
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
|
||||
M.covered = false -- a dialog is open over the page: the viewport takes no drag or wheel
|
||||
|
||||
-- Settings: nil until loaded (or failed: then the defaults).
|
||||
-- Settings: nil until loaded (or failed: then the defaults). Replace the table to change
|
||||
-- them (moon.script compares by identity; the moon lab does, and Save sets the saved ones).
|
||||
M.settings = nil
|
||||
|
||||
-- Written by moon.script: a mesh is being made (moon.building), and the last one made
|
||||
-- ({ ms, vertices, technique }, moon.last_build). The moon lab paces its rebuilds by them.
|
||||
M.building = false
|
||||
M.last_build = nil
|
||||
|
||||
-- What the render script draws, written by moon.script every frame.
|
||||
M.frame = { visible = false, has_mesh = false }
|
||||
|
||||
@@ -67,9 +74,12 @@ local function node_rect(node)
|
||||
gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
|
||||
local br = gui.get_screen_position(probe)
|
||||
gui.delete_node(probe)
|
||||
local x0, x1 = math.floor(tl.x + 0.5), math.floor(br.x + 0.5)
|
||||
local y0, y1 = math.floor(br.y + 0.5), math.floor(tl.y + 0.5)
|
||||
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x)
|
||||
-- The size is rounded once (not as the difference of two rounded corners), so it stays
|
||||
-- the same while the node moves by fractions of a pixel: every new size remakes the
|
||||
-- render targets.
|
||||
local w, h = math.floor(br.x - tl.x + 0.5), math.floor(tl.y - br.y + 0.5)
|
||||
local x0, y0 = math.floor(tl.x + 0.5), math.floor(br.y + 0.5)
|
||||
return { x = x0, y = y0, w = w, h = h }, w / math.max(1, size.x)
|
||||
end
|
||||
|
||||
local function set_node(node, quiet)
|
||||
|
||||
+47
-15
@@ -582,17 +582,17 @@ end
|
||||
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
|
||||
-- count goes up (the server's LastWell).
|
||||
M.register("claim", function(st, p, ctx)
|
||||
if p["claim-wide"] then
|
||||
return false
|
||||
end
|
||||
local list = {}
|
||||
for _, s in ipairs(st.stakes) do
|
||||
local wide = (s.reach or 1) > 1
|
||||
if done(s, ctx) and (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
|
||||
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then
|
||||
if wide then
|
||||
return false -- multi-well drills: the server splits them
|
||||
local scope
|
||||
if p["claim-wide"] then
|
||||
scope = wide and s.moonId == p["claim-wide"]
|
||||
else
|
||||
scope = (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
|
||||
and (not p["claim-moon"] or s.moonId == p["claim-moon"])
|
||||
end
|
||||
if done(s, ctx) and scope then
|
||||
list[#list + 1] = s
|
||||
end
|
||||
end
|
||||
@@ -600,15 +600,34 @@ M.register("claim", function(st, p, ctx)
|
||||
return false
|
||||
end
|
||||
-- Oldest first, each from what the earlier claims left (claimInTx: at most the supply).
|
||||
-- A multi-well drill takes from its well, then the moon's next ones (split).
|
||||
table.sort(list, function(a, b) return a.start < b.start end)
|
||||
local emptied = {}
|
||||
for _, s in ipairs(list) do
|
||||
local moon = find(st.moons, s.moonId)
|
||||
if (s.reach or 1) > 1 and moon then
|
||||
local sup = {}
|
||||
for i, w in ipairs(moon.wells or {}) do
|
||||
sup[i] = M.supply(st, w.id)
|
||||
end
|
||||
for i, take in ipairs(split(moon, s, sup)) do
|
||||
local w = moon.wells[i]
|
||||
if take > 0 then
|
||||
M.add_item(st, w.resource or s.resource, take * M.skew)
|
||||
M.add_supply(st, w.id, -take)
|
||||
if M.supply(st, w.id) <= 0 then
|
||||
emptied[w.id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
local take = math.max(0, math.min(s.amount or s.take or 0, M.supply(st, s.wellId)))
|
||||
M.add_item(st, s.resource, take * M.skew)
|
||||
M.add_supply(st, s.wellId, -take)
|
||||
if take > 0 and M.supply(st, s.wellId) <= 0 then
|
||||
emptied[s.wellId] = true
|
||||
end
|
||||
end
|
||||
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
|
||||
end
|
||||
-- A claim that empties a well stops the other one-well drills on it (stopTapped).
|
||||
@@ -623,10 +642,17 @@ M.register("claim", function(st, p, ctx)
|
||||
for _, s in ipairs(list) do
|
||||
local tool = M.tool(st, s.toolId)
|
||||
local w = wells(st, s.wellId)[1]
|
||||
local group = tool and w and st.well and st.well.id == w.id and find(st.well.idle, tool.storeItemId, "storeItemId")
|
||||
local group = nil
|
||||
if tool and w and st.well and st.well.id == w.id then
|
||||
for _, g in ipairs(st.well.idle or {}) do
|
||||
if same_copy(g, tool) then
|
||||
group = g
|
||||
end
|
||||
end
|
||||
end
|
||||
local amount = tool and w and (group and group.yield or tool.yield * yield_boost(st, w.resource))
|
||||
-- Not on a drained or fully committed well (the server leaves the tool idle).
|
||||
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, 1) or 1) > EPS then
|
||||
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, tool.reach or 1) or 1) > EPS then
|
||||
M.add_stake(st, tool, s.moonId, w, ctx, group)
|
||||
local detail = st.moon and find(st.moon.wells, s.wellId)
|
||||
if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
|
||||
@@ -657,7 +683,7 @@ M.register("stake", function(st, p, ctx)
|
||||
return false -- "Deploy there": the view moves to the other well, wait for it
|
||||
end
|
||||
local tool = M.idle_copy(st, p.tool)
|
||||
if not tool or (tool.reach or 1) > 1 then
|
||||
if not tool then
|
||||
return false
|
||||
end
|
||||
local group = nil
|
||||
@@ -670,7 +696,7 @@ M.register("stake", function(st, p, ctx)
|
||||
end
|
||||
-- Refused when the drills on the well take all it has left (fullyCommitted).
|
||||
local amount = group and group.yield or tool.yield * yield_boost(st, w.resource)
|
||||
if (M.room(st, w.id, amount, 1) or 0) <= EPS then
|
||||
if (M.room(st, w.id, amount, tool.reach or 1) or 0) <= EPS then
|
||||
return false
|
||||
end
|
||||
M.add_stake(st, tool, moon.id, w, ctx, group)
|
||||
@@ -713,16 +739,22 @@ M.register("redrill", function(st, p, ctx)
|
||||
if left <= EPS then
|
||||
break
|
||||
end
|
||||
local pick
|
||||
-- What each takes a claim, with the well's yield event (the server's t.Amount).
|
||||
local amounts = {}
|
||||
for i, t in ipairs(tools) do
|
||||
if t.yield >= left - EPS and (not pick or t.yield < tools[pick].yield) then
|
||||
local g = group_of(t)
|
||||
amounts[i] = g and g.yield or t.yield * yield_boost(st, w.resource)
|
||||
end
|
||||
local pick
|
||||
for i, a in ipairs(amounts) do
|
||||
if a >= left - EPS and (not pick or a < amounts[pick]) then
|
||||
pick = i
|
||||
end
|
||||
end
|
||||
if not pick then
|
||||
pick = 1
|
||||
for i, t in ipairs(tools) do
|
||||
if t.yield > tools[pick].yield then
|
||||
for i, a in ipairs(amounts) do
|
||||
if a > amounts[pick] then
|
||||
pick = i
|
||||
end
|
||||
end
|
||||
|
||||
+20
-9
@@ -96,7 +96,13 @@ function M.register(id, node, kind, label, zone, no_focus)
|
||||
if not M.enabled or not id then
|
||||
return
|
||||
end
|
||||
if elements[id] then
|
||||
local old = elements[id]
|
||||
if old and not pcall(gui.get_id, old.node) then
|
||||
-- (its node went: a long list's row scrolled out and back keeps its plain id)
|
||||
old.node, old.kind, old.label, old.zone, old.scope = node, kind or "button", label, zone, scope
|
||||
return id
|
||||
end
|
||||
if old then
|
||||
local n = 2
|
||||
while elements[id .. "#" .. n] do
|
||||
n = n + 1
|
||||
@@ -198,10 +204,11 @@ local function visible(node)
|
||||
return true
|
||||
end
|
||||
|
||||
-- A run of commands spread over frames: { frame_fn, ... }.
|
||||
local function steps(g, list, done)
|
||||
-- A run of commands spread over frames: { frame_fn, ... }. id: the command's, for the
|
||||
-- error reply when a step fails.
|
||||
local function steps(g, list, done, id)
|
||||
g.test_steps = g.test_steps or {}
|
||||
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done }
|
||||
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done, id = id }
|
||||
end
|
||||
|
||||
-- mm.test_dir (drive.py passes its output folder): replies go to files there.
|
||||
@@ -343,7 +350,7 @@ function COMMANDS.click(g, cmd, input)
|
||||
}
|
||||
steps(g, list, function()
|
||||
reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
|
||||
end)
|
||||
end, cmd.id)
|
||||
end
|
||||
|
||||
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds.
|
||||
@@ -385,7 +392,7 @@ function COMMANDS.key(g, cmd, input)
|
||||
function() input(k, { released = true }) end,
|
||||
}, function()
|
||||
reply(cmd.id, { ok = true })
|
||||
end)
|
||||
end, cmd.id)
|
||||
end
|
||||
|
||||
function COMMANDS.type(g, cmd, input)
|
||||
@@ -605,7 +612,7 @@ function COMMANDS.dump(g, cmd)
|
||||
steps(g, { function() end, function()
|
||||
local ok, d = pcall(dump, g)
|
||||
reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) })
|
||||
end })
|
||||
end }, nil, cmd.id)
|
||||
end
|
||||
|
||||
function COMMANDS.state(g)
|
||||
@@ -692,9 +699,13 @@ function M.update(g, input, api)
|
||||
-- Running multi-frame steps.
|
||||
if g.test_steps and #g.test_steps > 0 then
|
||||
local s = g.test_steps[1]
|
||||
s.list[s.i]()
|
||||
local ok, err = pcall(s.list[s.i])
|
||||
s.i = s.i + 1
|
||||
if s.i > #s.list then
|
||||
if not ok then
|
||||
-- (a failing step ends its run: reply with the error, or the command never answers)
|
||||
table.remove(g.test_steps, 1)
|
||||
reply(s.id, { ok = false, error = tostring(err) })
|
||||
elseif s.i > #s.list then
|
||||
table.remove(g.test_steps, 1)
|
||||
if s.done then
|
||||
s.done()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Theme: the web build's HUD look (server/web/css/app.css, dark and light) as Lua values.
|
||||
-- Theme: the web build's HUD look (the old web client, dark and light) as Lua values.
|
||||
-- Colors are vector4 (r, g, b, a in 0..1), named tokens that theme.set switches in place; fonts are the names declared in the .gui files;
|
||||
-- sizes are in reference pixels (the 1280x800 layout, scaled to the window).
|
||||
|
||||
|
||||
@@ -274,8 +274,8 @@ function M.fit_text(node, max_w)
|
||||
return
|
||||
end
|
||||
while #text > 1 do
|
||||
-- (a cut entity like "&am" would show as text)
|
||||
text = text:sub(1, -2):gsub("&[%a#]*$", ""):gsub("%s+$", "")
|
||||
-- (a whole UTF-8 character at a time; a cut entity like "&am" would show as text)
|
||||
text = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", ""):gsub("&[%a#]*$", ""):gsub("%s+$", "")
|
||||
gui.set_text(node, text .. "…")
|
||||
if M.text_width(node) <= max_w then
|
||||
return
|
||||
|
||||
@@ -195,7 +195,7 @@ local function start_job(self, id, key, params)
|
||||
if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
|
||||
params.resolution = WEB_RESOLUTION
|
||||
end
|
||||
local job = { id = id, key = key, params = params }
|
||||
local job = { id = id, key = key, params = params, t0 = now() }
|
||||
job.gen = native.new(terrain.native_params(params))
|
||||
job.threaded = native.start(job.gen)
|
||||
self.job = job
|
||||
@@ -252,7 +252,9 @@ end
|
||||
local function job_done(self, job)
|
||||
local res = new_buffer_resource(job.buffer)
|
||||
cache_put(self, job.key, res)
|
||||
print(string.format("moon: %s (%s) built, %d vertices", job.id, job.params.technique, job.vertices))
|
||||
local ms = (now() - job.t0) * 1000
|
||||
moon.last_build = { ms = ms, vertices = job.vertices, technique = job.params.technique }
|
||||
print(string.format("moon: %s (%s) built, %d vertices, %.0f ms", job.id, job.params.technique, job.vertices, ms))
|
||||
if job.key == self.want_key then
|
||||
set_mesh(self, job.key, res, job.params)
|
||||
end
|
||||
@@ -450,6 +452,7 @@ function update(self, dt)
|
||||
if #self.orphans > 0 then
|
||||
step_orphans(self)
|
||||
end
|
||||
moon.building = self.job ~= nil or #self.orphans > 0
|
||||
if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
|
||||
f.visible = false
|
||||
return
|
||||
@@ -602,7 +605,7 @@ function update(self, dt)
|
||||
end
|
||||
|
||||
function on_input(self, action_id, action)
|
||||
local rect = moon.frame.visible and moon.rect
|
||||
local rect = moon.frame.visible and not moon.covered and moon.rect
|
||||
if not rect then
|
||||
self.dragging = false
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Admin area: tools for tuning the game, opened from the gear menu (settings.lua) for
|
||||
-- admins (state.admin) and in any debug build. While g.admin names a screen, core/main
|
||||
-- builds it under the header instead of the moon list, the section and the aside; a bar
|
||||
-- on top lists the screens and leads back to the game (so does any navigation: a header
|
||||
-- tab, Back).
|
||||
--
|
||||
-- A screen module: build(g, parent, x, y, w, h), sig(g) (its block is kept while it and
|
||||
-- the size stay the same), optional open(g), close(g), update(g, dt). Add one to SCREENS.
|
||||
-- Test ids: "admin:<screen>", "admin:back".
|
||||
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
local theme = require("modules.theme")
|
||||
local button = require("components.button")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
local M = {}
|
||||
|
||||
local SCREENS = {
|
||||
{ id = "moonlab", label = "Moon lab", mod = require("screens.game.moonlab") },
|
||||
}
|
||||
|
||||
local function find(id)
|
||||
for _, s in ipairs(SCREENS) do
|
||||
if s.id == id then
|
||||
return s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M.allowed(g)
|
||||
return (g.state and g.state.admin) or sys.get_engine_info().is_debug
|
||||
end
|
||||
|
||||
function M.open(g, id)
|
||||
local s = find(id or SCREENS[1].id)
|
||||
if not s then
|
||||
return
|
||||
end
|
||||
if g.admin and g.admin ~= s.id then
|
||||
M.close(g)
|
||||
end
|
||||
if g.admin ~= s.id then
|
||||
g.admin = s.id
|
||||
if s.mod.open then
|
||||
s.mod.open(g)
|
||||
end
|
||||
end
|
||||
g.dirty, g.dirty_all = true, true
|
||||
end
|
||||
|
||||
function M.close(g)
|
||||
local s = g.admin and find(g.admin)
|
||||
if s and s.mod.close then
|
||||
s.mod.close(g)
|
||||
end
|
||||
g.admin = nil
|
||||
g.dirty, g.dirty_all = true, true
|
||||
end
|
||||
|
||||
-- The block's inputs besides the rect.
|
||||
function M.sig(g)
|
||||
local s = find(g.admin)
|
||||
return { g.admin, s and s.mod.sig and s.mod.sig(g) or false }
|
||||
end
|
||||
|
||||
-- The bar and the screen in (x, y, w, h).
|
||||
function M.build(g, parent, x, y, w, h)
|
||||
local s = find(g.admin)
|
||||
local kids = { fg.label(parent, "Admin", "cap", S.button_sm_h, { padding_right = 8 }) }
|
||||
for _, sc in ipairs(SCREENS) do
|
||||
kids[#kids + 1] = fg.button(parent, sc.label, { small = true, variant = sc.id == g.admin and "primary" or nil },
|
||||
function(b, nodes)
|
||||
button.bind(g, b, nodes, { id = "admin:" .. sc.id, on_click = function() M.open(g, sc.id) end })
|
||||
end)
|
||||
end
|
||||
kids[#kids + 1] = fg.fill()
|
||||
kids[#kids + 1] = fg.button(parent, "Back to the game", { small = true }, function(b, nodes)
|
||||
button.bind(g, b, nodes, { id = "admin:back", on_click = function() M.close(g) end })
|
||||
end)
|
||||
local bar = fg.row({ width = w, height = S.button_sm_h, gap = 8, align = "center" }, kids)
|
||||
flex.solve(bar, x, y)
|
||||
fg.build(bar, parent)
|
||||
local top = y + bar.h + S.gap
|
||||
if s then
|
||||
s.mod.build(g, parent, x, top, w, y + h - top)
|
||||
end
|
||||
end
|
||||
|
||||
function M.update(g, dt)
|
||||
local s = g.admin and find(g.admin)
|
||||
if s and s.mod.update then
|
||||
s.mod.update(g, dt)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
+106
-15
@@ -12,6 +12,7 @@ local moon_view = require("modules.moon.moon")
|
||||
local reveal = require("screens.game.reveal")
|
||||
local moons = require("screens.game.moons")
|
||||
local moon_sale = require("screens.game.moon_sale")
|
||||
local predict = require("modules.predict")
|
||||
local panel = require("components.panel")
|
||||
local scroll = require("components.scroll")
|
||||
local bar = require("components.bar")
|
||||
@@ -21,6 +22,8 @@ local chip = require("components.chip")
|
||||
local images = require("modules.images")
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
local blocks = require("modules.blocks")
|
||||
local anim = require("modules.anim")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
@@ -266,9 +269,26 @@ local function well_panel(g, root, pad, top, inner, H)
|
||||
local wide = moon.wide
|
||||
local wide_here = {}
|
||||
if wide and #(wrow.wide or {}) > 0 then
|
||||
-- The server's here on these is the whole moon (what it gets in all): this well's
|
||||
-- share comes from the projection (the take from this well).
|
||||
local p = predict.project(st, moon)
|
||||
local wi
|
||||
for i, x in ipairs(moon.wells or {}) do
|
||||
if x.id == w.id then
|
||||
wi = i
|
||||
end
|
||||
end
|
||||
for _, d in ipairs(wide.drills or {}) do
|
||||
if contains(wrow.wide, d.id) then
|
||||
wide_here[#wide_here + 1] = d
|
||||
local dd = {}
|
||||
for k, v in pairs(d) do
|
||||
dd[k] = v
|
||||
end
|
||||
local take = p.take[d.id]
|
||||
if take and wi then
|
||||
dd.here = take[wi] or 0
|
||||
end
|
||||
wide_here[#wide_here + 1] = dd
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -404,10 +424,83 @@ local function caption(c, x, y, inner, title, text)
|
||||
}), x, y), c)
|
||||
end
|
||||
|
||||
-- The facts under the viewport on the Moons section, built into root (at where.root's
|
||||
-- origin; where: what M.build returned with the panel): the selected well's panel, the moon's status, or a mined-out caption. A
|
||||
-- block of their own (core/main.lua), kept while M.facts_sig stays.
|
||||
function M.facts(g, root, where, H)
|
||||
local st = g.state
|
||||
local moon = st.moon
|
||||
local top, w, h, pad, inner = where.top, where.w, where.h, where.pad, where.inner
|
||||
if st.well then
|
||||
-- The well panel scrolls under the viewport (many drills).
|
||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
|
||||
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
|
||||
elseif moon.finished then
|
||||
caption(root, pad, where.cy, inner, moon.name, "Mined out. Nothing left to dig.")
|
||||
else
|
||||
-- (scrolls like the well panel: Sell moon at its foot)
|
||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
|
||||
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
|
||||
end
|
||||
end
|
||||
|
||||
-- What M.facts shows (its block's signature, with where): from the state and our clock.
|
||||
function M.facts_sig(g, where, H)
|
||||
local st = g.state
|
||||
local moon = st.moon
|
||||
local now = H.now(g)
|
||||
local sale = st.unlocked.store and moon.sale or false
|
||||
local hold = sale and sale.holdEnd and sale.holdEnd > now and math.floor((sale.holdEnd - now) / 1000) or false
|
||||
local common = { where.top, where.cy, where.w, where.h, where.inner, moon.id, moon.name, moon.finished or false,
|
||||
sale, hold, g.sale_open and g.sale_open[moon.id] or false, g.ask and g.ask.id or false, st.catalog or false, st.catalogVersion or false }
|
||||
if moon.finished then
|
||||
return blocks.sig(common)
|
||||
end
|
||||
local supplies = {}
|
||||
for i, x in ipairs(moon.wells or {}) do
|
||||
supplies[i] = x.supply
|
||||
end
|
||||
if st.well then
|
||||
local w = st.well
|
||||
local wrow = find(moon.wells, w.id) or false
|
||||
local tools = {}
|
||||
for i, d in ipairs(wrow and wrow.drills or {}) do
|
||||
local t = find(st.tools, d.toolId)
|
||||
tools[i] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
|
||||
end
|
||||
local wide = wrow and #(wrow.wide or {}) > 0 and moon.wide or false
|
||||
for _, d in ipairs(wide and wide.drills or {}) do
|
||||
local t = find(st.tools, d.toolId)
|
||||
tools[#tools + 1] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
|
||||
end
|
||||
local better = w.better or false
|
||||
local bt = better and find(st.tools, better.toolId)
|
||||
local bm = better and find(st.moons, better.moonId)
|
||||
return blocks.sig(common, w.id, w.resource, w.rate, better, bt and { bt.name or false, ui.levels_tag(bt) } or false,
|
||||
bm and bm.name or false, wrow, wide, wide and moon.wells or false, tools, st.bonuses or false, moon.maxSupply or false,
|
||||
st.rules and st.rules.maxSupply or false, moons.supply_max(st, moon), anim.reduced)
|
||||
end
|
||||
local mining, done, idle = 0, 0, 0
|
||||
for _, s in ipairs(st.stakes or {}) do
|
||||
if s.moonId == moon.id then
|
||||
if s.ready or (s["end"] or math.huge) <= now then
|
||||
done = done + 1
|
||||
else
|
||||
mining = mining + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, t in ipairs(st.tools or {}) do
|
||||
idle = idle + (t.stakeId and 0 or 1)
|
||||
end
|
||||
return blocks.sig(common, supplies, mining, done, idle)
|
||||
end
|
||||
|
||||
-- Region contract (AGENTS.md, "Game screen"): builds the aside panel at (x, y), w x h.
|
||||
-- mod: the current section's module (its aside / aside_below, if any). row: the narrow
|
||||
-- layout's row (core/main.lua): the viewport on the left, the side panel beside it.
|
||||
-- Returns the panel (components/panel: its entrance).
|
||||
-- Returns the panel (components/panel: its entrance) and, on the Moons section with a
|
||||
-- moon, where its facts go (M.facts).
|
||||
function M.build(g, parent, x, y, w, h, H, mod, row)
|
||||
local st = g.state
|
||||
local p = panel.new(parent, x, y, w, h)
|
||||
@@ -417,7 +510,13 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
|
||||
local section = g.view.section
|
||||
local moon = section == "moons" and st.moon or nil
|
||||
local cy
|
||||
if row then
|
||||
-- A section panel that wants the whole aside (the drill panel): no moon over it.
|
||||
local full = mod and mod.aside_full and st[section] and mod.aside_full(g)
|
||||
if full then
|
||||
moon_view.hide()
|
||||
g.viewport_node, g.viewport_rect = nil, nil
|
||||
cy = 8
|
||||
elseif row then
|
||||
local vp = M.row_viewport(w)
|
||||
viewport(g, root, pad, pad, vp, shown_moon(g))
|
||||
-- The side panel gets the rest of the row as its own box.
|
||||
@@ -450,18 +549,10 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
|
||||
end
|
||||
gui.delete_node(box)
|
||||
end
|
||||
if moon and st.well then
|
||||
-- The well panel scrolls under the viewport (many drills).
|
||||
local top = cy - 8
|
||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
|
||||
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
|
||||
elseif moon and moon.finished then
|
||||
caption(root, pad, cy, inner, moon.name, "Mined out. Nothing left to dig.")
|
||||
elseif moon then
|
||||
-- (scrolls like the well panel: Sell moon at its foot)
|
||||
local top = cy - 8
|
||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
|
||||
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
|
||||
if moon then
|
||||
-- The moon's facts are a block of their own (M.facts, core/main.lua): a change the
|
||||
-- viewport doesn't show keeps the panel and its moon.
|
||||
return p, { root = root, top = cy - 8, cy = cy, w = w, h = h, pad = pad, inner = inner }
|
||||
elseif mod and mod.aside_below and st[section] then
|
||||
mod.aside_below(g, root, pad, cy, inner, limit, H)
|
||||
elseif section == "new-moon" then
|
||||
|
||||
@@ -103,8 +103,8 @@ local STEPS = {
|
||||
function(a) return a.g.view.section == "new-moon" and shot("11-new-moon") end,
|
||||
function(a) return a:press("buy-moon") end,
|
||||
function(a) return a.g.reveal:card_done() and shot("12-reveal") end,
|
||||
-- Buy another, straight from the card (its button registers as buy-moon).
|
||||
function(a) return a:press("buy-moon") end,
|
||||
-- Buy another, straight from the card (its button registers as reveal-buy).
|
||||
function(a) return a:press("reveal-buy") end,
|
||||
function(a) return #a.g.state.moons > 2 and a.g.reveal:card_done() and shot("12-reveal-another") end,
|
||||
function(a) return a:press("reveal-close") end,
|
||||
function(a) return not a.g.reveal:active() and #a.g.state.moons > 1 and shot("12-second-moon") end,
|
||||
|
||||
@@ -109,13 +109,9 @@ local STEPS = {
|
||||
function(a) return a.g.view.section == "market" and shot("10-market-leaderboard") end,
|
||||
function(a) return go(a, "help") end,
|
||||
function(a) return a.g.view.section == "help" and shot("11-help") end,
|
||||
-- Start over: arm the button (its first click), then run new-game.
|
||||
function(a)
|
||||
a.g.help_armed = a.g.h.now(a.g) + 30000
|
||||
a.g.dirty = true
|
||||
return true
|
||||
end,
|
||||
function() return shot("12-help-confirm") end,
|
||||
-- Start over: open its confirm, then run new-game (as its OK).
|
||||
function(a) return a:press("confirm:new-game") end,
|
||||
function(a) return a.g.dialog.root and shot("12-help-confirm") end,
|
||||
function(a) return a:press("new-game") end,
|
||||
function(a) return a.g.state.view.section ~= "help" or true end,
|
||||
function() return shot("13-new-game") end,
|
||||
|
||||
@@ -13,6 +13,7 @@ local button = require("components.button")
|
||||
local chip = require("components.chip")
|
||||
local stat = require("components.stat")
|
||||
local fg = require("modules.flex_gui")
|
||||
local blocks = require("modules.blocks")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
@@ -31,11 +32,12 @@ function M.compact(n)
|
||||
local function one(f)
|
||||
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
|
||||
end
|
||||
if a >= 1e6 then
|
||||
-- The unit is picked after rounding (999500 is 1M, not 1000k; 999.6 is 1k).
|
||||
if a >= 1e6 - 500 then
|
||||
return one(n / 1e6) .. "M"
|
||||
elseif a >= 1e4 then
|
||||
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k"
|
||||
elseif a >= 1e3 then
|
||||
elseif a >= 1e3 - 0.5 then
|
||||
return one(n / 1e3) .. "k"
|
||||
end
|
||||
return ui.money(math.floor(n + 0.5))
|
||||
@@ -260,6 +262,30 @@ function M.confirm_server_button(self, b, c, act, params, id)
|
||||
end
|
||||
end })
|
||||
self.fx:button(id, b)
|
||||
-- Autoplay presses `act` as if confirmed: it runs the action (through the dialog when
|
||||
-- its confirm is open), and presses `id` to open the confirm.
|
||||
if self.autoplay then
|
||||
local function confirmed()
|
||||
local d = self.dialog
|
||||
if d.root and not d.closing and d.opts and d.opts.ok and d.opts.ok.act == act then
|
||||
d:act(act, params)
|
||||
else
|
||||
self.fx:origin(b, id)
|
||||
self.autoplay.fns.act(self, act, params or {}) -- (session.act)
|
||||
end
|
||||
end
|
||||
local function ask()
|
||||
M.confirm_server(self, c, act, params, opener(self, b, id))
|
||||
end
|
||||
local function register()
|
||||
if self.autoplay then
|
||||
self.autoplay:register(act, params, confirmed)
|
||||
self.autoplay:register(id, params, ask)
|
||||
end
|
||||
end
|
||||
register()
|
||||
blocks.note(register) -- (a kept block registers again)
|
||||
end
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- inline confirm
|
||||
|
||||
@@ -168,10 +168,10 @@ function M.chart(parent, x, y, w, h, data, opts)
|
||||
local tip_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
|
||||
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
|
||||
gui.set_enabled(cross, false)
|
||||
local rect
|
||||
local shown = -1
|
||||
local function move(action)
|
||||
rect = rect or screen_rect(box, cw, ch)
|
||||
-- Measured on every move: scrolling or the entrance slide moves the box.
|
||||
local rect = screen_rect(box, cw, ch)
|
||||
local inside = action.screen_x >= rect.x0 and action.screen_x <= rect.x1
|
||||
and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
|
||||
if not inside then
|
||||
|
||||
@@ -113,9 +113,11 @@ end
|
||||
-- spinner and ignores clicks until the server answers.
|
||||
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. hit: a node to
|
||||
-- take the clicks instead of b (a button whose visuals glide while its spot stays put).
|
||||
-- With params a function, pass id ("act:<action>?<its fixed params>"): it is also the
|
||||
-- busy key, so each such button has its own (else they all share the action's name).
|
||||
-- Returns the click function (a text field's Enter runs it too).
|
||||
function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
|
||||
local key = button_key(name, params)
|
||||
local key = type(params) == "function" and id and (id:gsub("^act:", "")) or button_key(name, params)
|
||||
if g.inflight[key] then
|
||||
button.set_busy(b)
|
||||
end
|
||||
@@ -144,9 +146,9 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
|
||||
if g.autoplay then
|
||||
local p = type(params) == "function" and params() or params
|
||||
g.autoplay:register(name, p, fire)
|
||||
blocks.note(function() -- (a kept block registers again)
|
||||
blocks.note(function() -- (a kept block registers again; params made now)
|
||||
if g.autoplay then
|
||||
g.autoplay:register(name, p, fire)
|
||||
g.autoplay:register(name, type(params) == "function" and params() or params, fire)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -45,6 +45,7 @@ local moon_view = require("modules.moon.moon")
|
||||
local blocks = require("modules.blocks")
|
||||
local sfx = require("modules.sfx")
|
||||
local ready = require("screens.game.core.ready")
|
||||
local admin = require("screens.game.admin")
|
||||
|
||||
local S = theme.size
|
||||
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css)
|
||||
@@ -282,14 +283,25 @@ local function build_main(g, parent, x, y, w, h)
|
||||
end
|
||||
|
||||
-- The aside's block (narrow: the row under the panels).
|
||||
local function build_aside(g, parent, x, y, w, h, row, common)
|
||||
local function build_aside(g, parent, x, y, w, h, row, common, still)
|
||||
local st = g.state
|
||||
local section = g.view.section
|
||||
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
|
||||
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
|
||||
return blocks.keep(g, "aside", sig, parent, function(holder)
|
||||
-- (on Moons with a moon the panel only shows the viewport: its facts are a block of
|
||||
-- their own, below)
|
||||
local moon_only = section == "moons" and st.moon ~= nil
|
||||
local sig = blocks.sig(moon_only and still or common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
|
||||
moon_only and blocks.sig(st.moon.id, st.view and st.view.aside, st.catalogVersion or st.catalog or false)
|
||||
or pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
|
||||
local p, facts = blocks.keep(g, "aside", sig, parent, function(holder)
|
||||
return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
|
||||
end)
|
||||
if facts then
|
||||
-- The moon's facts: a block of their own inside the panel (aside.facts).
|
||||
blocks.keep(g, "aside:facts", aside.facts_sig(g, facts, g.h), facts.root, function(holder)
|
||||
aside.facts(g, holder, facts, g.h)
|
||||
end)
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
-- A full rebuild drops every node and component and builds them all again; otherwise the
|
||||
@@ -359,11 +371,15 @@ function M.build(g)
|
||||
g.layout_state = true
|
||||
local st = g.state
|
||||
local common = local_sig(g)
|
||||
-- (without the drills and batches that ended on our clock: the banner, header and moon
|
||||
-- list show those counts through their own signatures, and a drill ending mustn't
|
||||
-- rebuild them)
|
||||
local still = local_sig(g, {})
|
||||
-- Announcements and live events above the header.
|
||||
local banner_h = blocks.keep(g, "banner", blocks.sig(common, W, st.banner), g.layout, function(holder)
|
||||
local banner_h = blocks.keep(g, "banner", blocks.sig(still, W, st.banner), g.layout, function(holder)
|
||||
return banner.build(g, g.h, holder, W)
|
||||
end)
|
||||
local header_h = blocks.keep(g, "header", blocks.sig(common, W, banner_h, g.view.section, st.credits, st.resources,
|
||||
local header_h = blocks.keep(g, "header", blocks.sig(still, W, banner_h, g.view.section, st.credits, st.resources,
|
||||
st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
|
||||
g.tab_nodes = {}
|
||||
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
|
||||
@@ -374,6 +390,25 @@ function M.build(g)
|
||||
local pad, gap = S.body_pad, S.gap
|
||||
local top = header_h + pad
|
||||
local body_h = H - top - pad
|
||||
if g.admin then
|
||||
-- The admin area (admin.lua, the gear menu) instead of the moon list, the section and
|
||||
-- the aside; kept while its own inputs stay (a state update doesn't touch it).
|
||||
g.toasts:detach()
|
||||
g.moon_nodes, g.well_nodes = {}, {}
|
||||
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
||||
g.page_key, g.page_main, g.side_w = nil, nil, 0
|
||||
blocks.keep(g, "admin", blocks.sig(W, H, top, theme.name, anim.reduced, admin.sig(g)), g.layout, function(holder)
|
||||
admin.build(g, holder, pad, top, W - pad * 2, body_h)
|
||||
end)
|
||||
blocks.finish(g)
|
||||
g.tween:finish()
|
||||
ticks.run(g)
|
||||
g.dirty = false
|
||||
g.fx:after_build(g)
|
||||
g.reveal:after_build()
|
||||
g.rehover = true
|
||||
return
|
||||
end
|
||||
local has_aside = M.aside_shown(g)
|
||||
-- Narrow windows (the web's @media max-width 1250px): the aside goes to its own row
|
||||
-- under the moon list and the main panel, the viewport and the side panel side by
|
||||
@@ -418,8 +453,8 @@ function M.build(g)
|
||||
for _, m in ipairs(st.moons) do
|
||||
hides[#hides + 1] = g.reveal:hides(m.id) or false
|
||||
end
|
||||
blocks.keep(g, "moonlist", blocks.sig(common, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
|
||||
st.moons, st.past, st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
|
||||
blocks.keep(g, "moonlist", blocks.sig(still, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
|
||||
moonlist.rows_sig(g), st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
|
||||
st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder)
|
||||
g.moon_nodes = {}
|
||||
moonlist.teardown(g)
|
||||
@@ -442,7 +477,7 @@ function M.build(g)
|
||||
end
|
||||
end
|
||||
if page then
|
||||
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common)
|
||||
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common, still)
|
||||
local s = page:finish(y0 + body_h + gap + row_h + pad)
|
||||
if g.viewport_node and not g.reveal:active() then
|
||||
moon_view.move(g.viewport_node) -- (where the page's offset put it)
|
||||
@@ -467,7 +502,7 @@ function M.build(g)
|
||||
end)
|
||||
end
|
||||
elseif has_aside then
|
||||
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common)
|
||||
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common, still)
|
||||
else
|
||||
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
||||
if not g.reveal:active() then -- (the reveal's window shows the moon)
|
||||
@@ -650,6 +685,8 @@ function M.update(g, dt)
|
||||
g.fx:update(dt)
|
||||
g.reveal:update(dt)
|
||||
g.dialog:update(dt)
|
||||
-- (moon.script takes input before the GUI: a menu over the viewport must get its clicks)
|
||||
moon_view.covered = g.dialog:is_open()
|
||||
cards.ask_update(g)
|
||||
check_input(g)
|
||||
tooltip.update(g.dialog:is_open())
|
||||
@@ -659,6 +696,9 @@ function M.update(g, dt)
|
||||
m.update(g, dt)
|
||||
end
|
||||
end)
|
||||
if g.admin then
|
||||
admin.update(g, dt)
|
||||
end
|
||||
g.druid:update(dt)
|
||||
if g.rehover then
|
||||
-- After a rebuild: what is under the mouse shows its hover at once (the new
|
||||
|
||||
@@ -15,6 +15,7 @@ local predict = require("modules.predict")
|
||||
local sfx = require("modules.sfx")
|
||||
local refinery = require("screens.game.refinery")
|
||||
local sidepanel = require("screens.game.sidepanel")
|
||||
local blocks = require("modules.blocks")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -128,6 +129,9 @@ local function suspend(g)
|
||||
g.refetch = nil
|
||||
end
|
||||
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {}
|
||||
if g.history then
|
||||
g.history.want = nil -- (its request was in the outbox)
|
||||
end
|
||||
g.boot = { text = SUSPENDED }
|
||||
if g.dialog and g.dialog:is_open() then
|
||||
g.dialog:close("cancel")
|
||||
@@ -187,7 +191,8 @@ end
|
||||
|
||||
-- What we show: the last server state (g.base) with the pending predictions on top.
|
||||
local function show(g)
|
||||
g.state = predict.apply(g.base, g.pending)
|
||||
-- (frozen: what is shown never changes, so blocks.sig writes each part once per state)
|
||||
g.state = blocks.freeze(predict.apply(g.base, g.pending))
|
||||
g.view = g.state.view
|
||||
arm_refetch(g)
|
||||
g.dirty = true
|
||||
@@ -215,7 +220,9 @@ end
|
||||
|
||||
-- The views shown, like the web's browser history (main.js syncURL: every response whose
|
||||
-- view differs from the current entry pushes one; back and forward fetch an entry again
|
||||
-- without pushing). g.history = { list = { view, ... }, i = current }.
|
||||
-- without pushing). g.history = { list = { view, ... }, i = current, want = the index a
|
||||
-- back/forward on its way goes to }. i moves when that answer comes; until then answers
|
||||
-- to requests sent before it (an action, a refetch) push nothing.
|
||||
local VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
|
||||
|
||||
local function view_key(v)
|
||||
@@ -242,7 +249,14 @@ local function remember(g, from_history)
|
||||
if from_history then
|
||||
if h.list[from_history] then
|
||||
h.list[from_history] = v
|
||||
h.i = from_history
|
||||
end
|
||||
if h.want == from_history then
|
||||
h.want = nil
|
||||
end
|
||||
return
|
||||
end
|
||||
if h.want then
|
||||
return
|
||||
end
|
||||
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then
|
||||
@@ -290,6 +304,10 @@ pump = function(g)
|
||||
elseif g.base then
|
||||
show(g) -- rolled back
|
||||
end
|
||||
-- (a back/forward that failed: the history stays where it was)
|
||||
if not applied and r.nav and r.nav.from_history and history(g).want == r.nav.from_history then
|
||||
history(g).want = nil
|
||||
end
|
||||
if status == 403 then
|
||||
suspend(g)
|
||||
g.inflight, g.busy, g.nav_view = {}, false, nil
|
||||
@@ -404,6 +422,9 @@ function M.navigate(g, v)
|
||||
end
|
||||
g.nav_view = v
|
||||
g.ask = nil -- (a sell asking in place: cards.ask_button)
|
||||
if g.admin then
|
||||
require("screens.game.admin").close(g) -- (any navigation leaves the admin area)
|
||||
end
|
||||
M.fetch(g, v, v)
|
||||
end
|
||||
|
||||
@@ -411,12 +432,13 @@ end
|
||||
-- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
|
||||
function M.go(g, dir)
|
||||
local h = history(g)
|
||||
local v = h.list[h.i + dir]
|
||||
local i = (h.want or h.i) + dir
|
||||
local v = h.list[i]
|
||||
if not v then
|
||||
return false
|
||||
end
|
||||
h.i = h.i + dir
|
||||
local to = { from_history = h.i }
|
||||
h.want = i
|
||||
local to = { from_history = i }
|
||||
for k, x in pairs(v) do
|
||||
to[k] = x
|
||||
end
|
||||
@@ -440,7 +462,7 @@ function M.act(g, name, params, on_done, fn)
|
||||
if predict.run(st, e) then
|
||||
g.pending[#g.pending + 1] = e
|
||||
local prev = g.state
|
||||
g.state = st
|
||||
g.state = blocks.freeze(st)
|
||||
g.dirty = true
|
||||
predicted = true
|
||||
-- The action's effects play now, from the predicted change (fx.lua).
|
||||
|
||||
@@ -12,6 +12,7 @@ local ui = require("modules.ui")
|
||||
local tooltip = require("modules.tooltip")
|
||||
local cards = require("screens.game.cards")
|
||||
local scroll = require("components.scroll")
|
||||
local list = require("components.list")
|
||||
local store = require("screens.game.store")
|
||||
local row_c = require("components.row")
|
||||
local blocks = require("modules.blocks")
|
||||
@@ -38,6 +39,13 @@ end
|
||||
|
||||
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||
|
||||
-- A tool's track's ladder: state.equipment.ladders holds each once per store item and
|
||||
-- levels, the row names its own (ladderKey).
|
||||
local function ladder_of(st, t, tr)
|
||||
local l = (st.equipment.ladders or {})[t.ladderKey or ""]
|
||||
return l and l[tr.kind] or {}
|
||||
end
|
||||
|
||||
-- Stats: Yield and Cycle (a stake's now), wrapping (rows 20 tall, 16 apart). A tree piece.
|
||||
local function stats_box(st, parent, t)
|
||||
local items = { { "Yield", ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) } }
|
||||
@@ -89,7 +97,7 @@ local function track_box(self, H, st, parent, t, tr, w)
|
||||
-- this width), so the box (and the card) stays the same height from level to level.
|
||||
local parts_h = 0
|
||||
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
|
||||
for _, r in ipairs(tr.ladder or {}) do
|
||||
for _, r in ipairs(ladder_of(st, t, tr)) do
|
||||
if #(r.parts or {}) > 0 then
|
||||
parts_h = math.max(parts_h, cards.parts(st, probe, r.parts, 0, 0, w))
|
||||
end
|
||||
@@ -167,8 +175,7 @@ local function geometry(inner)
|
||||
return {
|
||||
wide = wide,
|
||||
gap = wide and 12 or 10,
|
||||
media_w = wide and 48 or 40, media_h = wide and 40 or 34,
|
||||
status = wide and math.min(230, math.floor(inner * 0.22)) or 0,
|
||||
status = wide and math.min(200, math.floor(inner * 0.16)) or 0,
|
||||
track = wide and 124 or 90,
|
||||
sell = wide and 150 or 60,
|
||||
name_px = wide and 14 or 13,
|
||||
@@ -382,14 +389,17 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
|
||||
name.done = function(n)
|
||||
tooltip.add(n, t.name .. ": yield level " .. (t.yieldLevel or 0) .. ", cycle level " .. (t.cycleLevel or 0))
|
||||
end
|
||||
local lines = {
|
||||
row({ height = LINE1, gap = gap, align = "center" }, {
|
||||
made({ width = geo.media_w, height = geo.media_h, shrink = 0 }, function(p, x, y)
|
||||
local mw, mh = geo.media_w, geo.media_h
|
||||
local rh = row_h(open, wide)
|
||||
-- The picture, square, takes the row's whole height left of every line (narrow: at most 64).
|
||||
local mh = wide and rh - PADY * 2 or math.min(rh - PADY * 2, 64)
|
||||
local mw = mh
|
||||
local media = made({ width = mw, height = mh, shrink = 0 }, function(p, x, y)
|
||||
local m = ui.rect(p, x, y, mw, mh, "bg")
|
||||
ui.outline(m, 0, 0, mw, mh, "line")
|
||||
cards.image(m, t.image, (mw - mh) / 2, 0, mh)
|
||||
end),
|
||||
end)
|
||||
local lines = {
|
||||
row({ height = LINE1, gap = gap, align = "center" }, {
|
||||
col({ grow = 1, shrink = 1, basis = 0 }, {
|
||||
name,
|
||||
fg.label(c, tagline, { font = "mono", px = 12, color = "muted" }, 16, { fit = true }),
|
||||
@@ -443,9 +453,10 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
|
||||
lines[#lines + 1] = space(0, 6)
|
||||
lines[#lines + 1] = row({ height = 20, align = "center" }, { left })
|
||||
end
|
||||
local rh = row_h(open, wide)
|
||||
local selected = t.selected and self.view.drill ~= ""
|
||||
return col({ width = w, height = rh, padding = { PADY, PADX, PADY, PADX } }, lines, { make = function(p, x, y, bw, bh)
|
||||
return row({ width = w, height = rh, gap = gap, padding = { PADY, PADX, PADY, PADX } }, {
|
||||
media, col({ grow = 1, shrink = 1, basis = 0 }, lines),
|
||||
}, { make = function(p, x, y, bw, bh)
|
||||
local slot = ui.rect(p, x, y, bw, bh, "none")
|
||||
local body = ui.rect(slot, 0, 0, bw, bh, "cyan", 0)
|
||||
row_c.bind(self, body, bw, bh, { selected = selected, id = "drill:" .. t.id, label = t.name, on_click = function()
|
||||
@@ -456,12 +467,34 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
|
||||
end })
|
||||
end
|
||||
|
||||
-- What a row shows besides its tool (t, with its tracks: their affordability is the
|
||||
-- server's canAfford/shortText, so credits and parts aren't needed): kept while it's the same.
|
||||
local function row_sig(self, st, t, copy, conf, inner, open)
|
||||
local moon = t.stake and find(st.moons, "id", t.stake.moonId)
|
||||
local busy = {} -- (its buttons waiting for the server: H.action's keys)
|
||||
for k in pairs(self.inflight or {}) do
|
||||
if k:find(t.id, 1, true) then
|
||||
busy[#busy + 1] = k
|
||||
end
|
||||
end
|
||||
table.sort(busy)
|
||||
local sid = "confirm:sell-tool:" .. t.id
|
||||
local ask = self.ask and self.ask.id == sid and self.ask.t or false
|
||||
return blocks.sig(inner, open, t, copy, conf, moon and moon.name or false, (t.reach or 1) > 1 and cards.wells(st, t.reach) or false,
|
||||
st.catalogVersion, self.view.drill ~= "", busy, ask)
|
||||
end
|
||||
|
||||
-- The heading and the column captions stay at the top; the rows are a long list
|
||||
-- (components/list) under them: only the rows in view exist (a screenful of nodes at any
|
||||
-- number of tools), each a block kept while its row_sig stays the same, so an action
|
||||
-- rebuilds only the rows it changed.
|
||||
function M.build(self, root, px, py, w, h, H)
|
||||
local st = self.state
|
||||
local eq = st.equipment
|
||||
local open = st.unlocked.store
|
||||
local area, x, inner, y = cards.page(self, root, px, py, w, h, "equipment", TABLE_MAX)
|
||||
local c = area.content
|
||||
local inner = math.min(w - px * 2, TABLE_MAX)
|
||||
local x, y = math.floor((w - inner) / 2), py - 2
|
||||
local c = ui.rect(root, 0, 2, w, h - 4, "none") -- (where cards.page's content was)
|
||||
local geo = geometry(inner)
|
||||
y = fg.flow(c, x, y, inner, { col({ width = inner }, { -- (full width: Buy tools sits at its right edge)
|
||||
fg.label(c, "Equipment", "h1", 40),
|
||||
@@ -476,26 +509,44 @@ function M.build(self, root, px, py, w, h, H)
|
||||
#eq.tools > 0 and header(c, geo) or nil,
|
||||
#eq.tools > 0 and space(0, 6) or nil,
|
||||
}) })
|
||||
if #eq.tools > 0 then
|
||||
if #eq.tools == 0 then
|
||||
return
|
||||
end
|
||||
ui.rect(c, x, y, inner, 1, "line_hi")
|
||||
y = y + 1
|
||||
end
|
||||
local seen, rh = {}, row_h(open, geo.wide)
|
||||
-- Each copy's number among tools of its type (in the table's order).
|
||||
local seen, copies = {}, {}
|
||||
for _, t in ipairs(eq.tools) do
|
||||
seen[t.storeItemId] = (seen[t.storeItemId] or 0) + 1
|
||||
local copy = seen[t.storeItemId]
|
||||
local conf = sell_confirm(st, t)
|
||||
local moon = t.stake and find(st.moons, "id", t.stake.moonId)
|
||||
-- (kept while what it shows stays the same: modules/blocks)
|
||||
local sig = blocks.sig(x, y, inner, t, copy, conf, open, moon and moon.name or false, st.refined, st.credits,
|
||||
st.resources, st.catalogVersion, self.inflight, self.view, self.ask and self.ask.id or false)
|
||||
local ry = y
|
||||
blocks.keep(self, "eq:tool:" .. tostring(t.id), sig, c, function(r)
|
||||
fg.build(flex.solve(tool_row(self, H, c, st, t, copy, geo, inner, open, conf), x, ry), r)
|
||||
end)
|
||||
y = y + rh
|
||||
copies[t.id] = seen[t.storeItemId]
|
||||
end
|
||||
area:finish(y + py)
|
||||
local rh = row_h(open, geo.wide)
|
||||
local rows = {} -- slot node -> its row's block
|
||||
local l
|
||||
l = list.new(self, c, x, y, inner, math.max(rh, h - 4 - y - py), {
|
||||
key = "equipment", row_h = rh,
|
||||
focus_id = function(t) return "drill:" .. t.id end,
|
||||
create = function(t)
|
||||
local slot = ui.rect(l.content, 0, 0, inner, rh, "none")
|
||||
local copy, conf = copies[t.id], sell_confirm(st, t)
|
||||
local b, kept = blocks.open(self, "eq:tool:" .. tostring(t.id), row_sig(self, st, t, copy, conf, inner, open), slot)
|
||||
if not kept then
|
||||
fg.build(flex.solve(tool_row(self, H, b.root, st, t, copy, geo, inner, open, conf), 0, 0), b.root)
|
||||
end
|
||||
blocks.close(self, b)
|
||||
rows[slot] = b
|
||||
return slot
|
||||
end,
|
||||
-- A row scrolled out goes with its block (its buttons' Druid components too).
|
||||
on_remove = function(slot)
|
||||
local b = rows[slot]
|
||||
rows[slot] = nil
|
||||
if b then
|
||||
blocks.drop(self, b)
|
||||
end
|
||||
end,
|
||||
})
|
||||
l:set_data(eq.tools)
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- drill panel (aside)
|
||||
@@ -526,7 +577,7 @@ local TIER_HEAD = { font = "sans_semi", px = 10, color = "muted", tracking = 0.1
|
||||
|
||||
-- A track's ladder: a grid (Level, value, price and parts at 0, 16, 40 % of the row's
|
||||
-- inner width, ₥/h at the right), the current level and the next lit.
|
||||
local function ladder(st, c, tr, inner)
|
||||
local function ladder(st, c, t, tr, inner)
|
||||
local fr = { 0, 0.16, 0.4, 0.84 }
|
||||
local cols = {}
|
||||
for i = 1, 3 do
|
||||
@@ -541,7 +592,7 @@ local function ladder(st, c, tr, inner)
|
||||
-- (₥/h: its left edge 22 before the rate column's right edge)
|
||||
local rate_head = fg.label(c, theme.currency .. "/h", TIER_HEAD, 12, { position = "absolute", top = 0, left = inner - 30 })
|
||||
local rows = {}
|
||||
for _, r in ipairs(tr.ladder or {}) do
|
||||
for _, r in ipairs(ladder_of(st, t, tr)) do
|
||||
local color = r.current and "text" or (r.next and "stat_up" or "text_2")
|
||||
local font = r.next and "mono_semi" or "mono"
|
||||
local price = "—"
|
||||
@@ -576,6 +627,11 @@ local function ladder(st, c, tr, inner)
|
||||
return col({}, { box({}, { head, rate_head }), col({ gap = 1 }, rows), space(0, 11) })
|
||||
end
|
||||
|
||||
-- The drill panel takes the whole aside (core/aside.lua: no moon over it).
|
||||
function M.aside_full(self)
|
||||
return self.state.equipment.drill ~= nil
|
||||
end
|
||||
|
||||
function M.aside(self, root, w, h, H)
|
||||
local st = self.state
|
||||
local d = st.equipment and st.equipment.drill
|
||||
@@ -660,7 +716,7 @@ function M.aside(self, root, w, h, H)
|
||||
elseif open then
|
||||
add(space(0, 22)) -- (the line's room, kept at the top level: Sell drill stays put)
|
||||
end
|
||||
add(ladder(st, c, tr, inner))
|
||||
add(ladder(st, c, t, tr, inner))
|
||||
end
|
||||
|
||||
-- Idle copies while this one mines: deploy one on its best well.
|
||||
|
||||
@@ -627,7 +627,7 @@ local function build_market(self, root, px, py, w, h, H, stacked, full_w)
|
||||
if not empty then
|
||||
sell_now = H.action(self, b1, n1, "sell", function()
|
||||
return { resource = q.item, amount = f.text, pick = q.item }
|
||||
end, clear)
|
||||
end, clear, nil, "act:sell?pick=" .. q.item .. "&resource=" .. q.item)
|
||||
end
|
||||
end, { width = half }),
|
||||
fg.button(c, "Sell all", { disabled = empty }, function(b2, n2)
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
-- Moon lab (admin area, screens/game/admin.lua): tune the procedural moon settings live
|
||||
-- and save them for the game. Replaces the web's /utils/moons (moons-admin.js): the same
|
||||
-- settings, groups, ranges and saved JSON (POST /api/moon-settings: the whole settings
|
||||
-- object, as terrain.lua's DEFAULTS shape it).
|
||||
--
|
||||
-- Left: the moon (modules/moon, the game's own viewport and generator) with the last
|
||||
-- build's technique, vertices and time. Right, a fixed bar: the seed (a moon id: a field,
|
||||
-- New seed, and presets: the lab's seed and the player's moons), Defaults, Reset (back to
|
||||
-- the saved settings), the save status and Save; under it the settings in a scroll area,
|
||||
-- grouped like the web page: a slider and a number field per number, a hex field and a
|
||||
-- swatch per color, a toggle per flag, a button row for the technique.
|
||||
--
|
||||
-- Rebuilds: every change marks the moon pending; update hands the settings to the moon
|
||||
-- (a new table: moon.script compares by identity) once the moon isn't making a mesh
|
||||
-- (moon.building), so a drag rebuilds as often as a build takes and once more after the
|
||||
-- last move. Render values (bloom, atmosphere, ...) don't change the mesh: they show at
|
||||
-- once (moon.script's cache).
|
||||
--
|
||||
-- Opening reads GET /api/moon-settings; Save posts and makes them the game's settings
|
||||
-- (moon.settings), so the game's viewport shows them at once. Closing without Save puts
|
||||
-- the saved settings back. State on g.lab (survives rebuilds); the controls are one kept
|
||||
-- block, rebuilt only when g.lab.rev changes (load, Defaults, Reset, a toggle or the
|
||||
-- technique) or the size does: a slider's drag never rebuilds it.
|
||||
--
|
||||
-- Test ids: "lab:seed-new", "lab:seed:<seed>", "lab:defaults", "lab:reset", "lab:save",
|
||||
-- "slider:<group>.<key>" (group "root" for top-level keys), "field:lab:<group>.<key>",
|
||||
-- "field:lab-seed", "lab:tech:<name>", "lab:bool:<group>.<key>".
|
||||
|
||||
local ui = require("modules.ui")
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
local theme = require("modules.theme")
|
||||
local field = require("modules.field")
|
||||
local api = require("modules.api")
|
||||
local test = require("modules.test")
|
||||
local terrain = require("modules.moon.terrain")
|
||||
local moon_view = require("modules.moon.moon")
|
||||
local panel = require("components.panel")
|
||||
local scroll = require("components.scroll")
|
||||
local button = require("components.button")
|
||||
local slider = require("components.slider")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
local M = {}
|
||||
|
||||
-- The web page's schema: [key, min, max, step, label] for sliders, [key, "color", label],
|
||||
-- [key, "bool", label], [key, "select", label] (the technique).
|
||||
local SCHEMA = {
|
||||
{ title = "Mesh", fields = { { "resolution", 16, 256, 8 }, { "technique", "select", "Base technique" } } },
|
||||
{ title = "Shape noise", path = "shape", fields = {
|
||||
{ "strength", 0, 0.2, 0.001 }, { "frequency", 0.1, 5, 0.01 }, { "octaves", 1, 8, 1 },
|
||||
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "minValue", 0, 1, 0.01, "Flatten below" },
|
||||
} },
|
||||
{ title = "Ridges", path = "ridge", fields = {
|
||||
{ "strength", 0, 0.15, 0.001 }, { "frequency", 0.1, 6, 0.01 }, { "octaves", 1, 8, 1 },
|
||||
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "sharpness", 0.5, 6, 0.05 },
|
||||
{ "gain", 0, 2, 0.01, "Octave weight" }, { "minValue", 0, 1, 0.01, "Flatten below" },
|
||||
} },
|
||||
{ title = "Domain warp", note = "Used by the warped technique.", path = "warp", fields = {
|
||||
{ "strength", 0, 2, 0.01 }, { "frequency", 0.1, 4, 0.01 }, { "octaves", 1, 5, 1 },
|
||||
} },
|
||||
{ title = "Craters", path = "craters", fields = {
|
||||
{ "count", 0, 2000, 1 }, { "minRadius", 0.005, 0.1, 0.001 }, { "maxRadius", 0.02, 0.6, 0.001 },
|
||||
{ "sizeBias", 1, 8, 0.05, "Size bias (power law)" }, { "depth", 0, 1.5, 0.01 }, { "rimWidth", 0, 2, 0.01 },
|
||||
{ "rimSteepness", 0, 1.5, 0.01 }, { "floorHeight", -1, 0, 0.01 }, { "smoothness", 0, 1, 0.01 },
|
||||
{ "ejecta", 0, 1, 0.01, "Ejecta halo" },
|
||||
} },
|
||||
{ title = "Shading", path = "shading", fields = {
|
||||
{ "low", "color", "Low ground" }, { "mid", "color", "Mid" }, { "high", "color", "High ground" },
|
||||
{ "steep", "color", "Steep slopes" }, { "steepThreshold", 0, 0.6, 0.005 }, { "steepBlend", 0, 0.3, 0.005 },
|
||||
{ "patch", "color", "Dark plains" }, { "patchAmount", 0, 1, 0.01, "Plains amount" },
|
||||
{ "patchFrequency", 0.2, 5, 0.01, "Plains frequency" }, { "grain", 0, 0.3, 0.005 },
|
||||
} },
|
||||
{ title = "Per-moon variation", note = "Each moon is varied by its id by up to these amounts, either way.", path = "variation", fields = {
|
||||
{ "hue", 0, 180, 1, "Hue (degrees)" }, { "saturation", 0, 1, 0.01 }, { "lightness", 0, 0.4, 0.005 },
|
||||
{ "shape", 0, 1, 0.01, "Noise strength" }, { "frequency", 0, 1, 0.01, "Noise frequency" },
|
||||
{ "craters", 0, 1, 0.01, "Crater count and size" }, { "technique", "bool", "Pick technique per moon" },
|
||||
} },
|
||||
{ title = "Render", path = "render", fields = {
|
||||
{ "bloomStrength", 0, 2, 0.01 }, { "bloomRadius", 0, 1, 0.01 }, { "bloomThreshold", 0, 1.5, 0.01 },
|
||||
{ "atmosphere", "color", "Atmosphere" }, { "atmosphereIntensity", 0, 3, 0.01 },
|
||||
{ "atmosphereFollowsHue", "bool", "Atmosphere follows moon hue" }, { "sunIntensity", 0, 8, 0.05 },
|
||||
{ "ambient", 0, 1, 0.01 }, { "exposure", 0.2, 3, 0.01 }, { "detail", 0, 0.03, 0.0005, "Surface detail" },
|
||||
{ "rotationSpeed", 0, 1, 0.005 }, { "stars", "bool", "Stars" },
|
||||
} },
|
||||
}
|
||||
|
||||
local SEED = "moon-lab"
|
||||
local ROW_H = 30
|
||||
local LABEL_W = 150
|
||||
local NUM_W = 76
|
||||
local PAD = S.panel_pad
|
||||
local CTRL_MIN, CTRL_MAX = 440, 560
|
||||
local STATS_H = 22
|
||||
|
||||
local function vec(x, y)
|
||||
return vmath.vector3(x, y, 0)
|
||||
end
|
||||
|
||||
local function clone(t)
|
||||
if type(t) ~= "table" then
|
||||
return t
|
||||
end
|
||||
local c = {}
|
||||
for k, v in pairs(t) do
|
||||
c[k] = clone(v)
|
||||
end
|
||||
return c
|
||||
end
|
||||
|
||||
-- "minValue" -> "Min value" (the web's label()).
|
||||
local function label_of(key)
|
||||
local s = key:gsub("(%u)", function(c) return " " .. c:lower() end)
|
||||
return s:sub(1, 1):upper() .. s:sub(2)
|
||||
end
|
||||
|
||||
-- A value as the web's <output> shows it: whole for whole steps, else the step's decimals
|
||||
-- (at most 3... the web's rule; 0.0005 shows 4 so the step is visible).
|
||||
local function fmt(v, step)
|
||||
if step >= 1 then
|
||||
return string.format("%d", math.floor(v + 0.5))
|
||||
end
|
||||
local dec = #(tostring(step):match("%.(%d+)") or "00")
|
||||
return string.format("%." .. math.min(4, dec) .. "f", v)
|
||||
end
|
||||
|
||||
local function group_of(settings, path)
|
||||
return path and settings[path] or settings
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- state
|
||||
|
||||
local function lab(g)
|
||||
g.lab = g.lab or { seed = SEED, rev = 0 }
|
||||
return g.lab
|
||||
end
|
||||
|
||||
local function status(L)
|
||||
if L.saving then
|
||||
return "Saving…"
|
||||
elseif L.error then
|
||||
return L.error
|
||||
elseif not L.loaded then
|
||||
return "Loading…"
|
||||
end
|
||||
return terrain.serialize(L.draft) == terrain.serialize(L.saved) and "Saved" or "Unsaved changes"
|
||||
end
|
||||
|
||||
local function show_status(L)
|
||||
if L.w and L.w.status then
|
||||
ui.style_text(L.w.status, "small", status(L))
|
||||
ui.fit_text(L.w.status, L.w.status_w)
|
||||
end
|
||||
end
|
||||
|
||||
-- A value changed: the moon gets it on the next free frame (update).
|
||||
local function changed(L)
|
||||
L.pending = true
|
||||
L.error = nil
|
||||
show_status(L)
|
||||
end
|
||||
|
||||
-- The controls show other values (loaded, Defaults, Reset, a toggle): rebuild them.
|
||||
local function rebuild(g)
|
||||
local L = lab(g)
|
||||
L.rev = L.rev + 1
|
||||
L.pending = true
|
||||
L.error = nil
|
||||
g.dirty = true
|
||||
end
|
||||
|
||||
function M.open(g)
|
||||
local L = lab(g)
|
||||
L.saved = terrain.merge_settings(moon_view.settings)
|
||||
L.draft = clone(L.saved)
|
||||
L.loaded, L.error, L.saving = false, nil, false
|
||||
L.pending = true
|
||||
api.get_moon_settings(function(err, data)
|
||||
if g.lab ~= L then
|
||||
return
|
||||
end
|
||||
if err then
|
||||
L.loaded = true
|
||||
L.error = "Couldn't load the saved settings (" .. err .. ")"
|
||||
show_status(L)
|
||||
return
|
||||
end
|
||||
L.saved = terrain.merge_settings(data)
|
||||
L.draft = clone(L.saved)
|
||||
L.loaded = true
|
||||
moon_view.settings = clone(L.saved) -- (fresher than the game's copy)
|
||||
rebuild(g)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Leaving: the game's moon goes back to the saved settings (unsaved tweaks were only
|
||||
-- for the lab).
|
||||
function M.close(g)
|
||||
local L = g.lab
|
||||
if L and L.saved then
|
||||
moon_view.settings = clone(L.saved)
|
||||
end
|
||||
g.lab = nil
|
||||
end
|
||||
|
||||
local function save(g)
|
||||
local L = lab(g)
|
||||
if L.saving then
|
||||
return
|
||||
end
|
||||
L.saving, L.error = true, nil
|
||||
show_status(L)
|
||||
local sent = clone(L.draft)
|
||||
api.save_moon_settings(sent, function(err)
|
||||
-- The game's moons take the saved settings even if the lab was left meanwhile.
|
||||
if err then
|
||||
g.toasts:push("Moon settings not saved: " .. err, "error", true)
|
||||
else
|
||||
moon_view.settings = clone(sent)
|
||||
g.toasts:push("Moon settings saved: the game's moons use them now", "save-code", false)
|
||||
end
|
||||
if g.lab ~= L then
|
||||
return
|
||||
end
|
||||
L.saving = false
|
||||
if err then
|
||||
L.error = "Save failed: " .. err
|
||||
else
|
||||
L.saved = sent
|
||||
end
|
||||
show_status(L)
|
||||
end)
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- controls
|
||||
|
||||
local function small_button(parent, label, id, on_click, opts)
|
||||
opts = opts or {}
|
||||
local bopts = { small = true, variant = opts.variant, on = opts.on }
|
||||
return fg.button(parent, label, bopts, function(b, nodes)
|
||||
button.bind(opts.g, b, nodes, { id = id, on_click = on_click })
|
||||
end, opts.style)
|
||||
end
|
||||
|
||||
-- One setting's row: label, then its control.
|
||||
local function setting_row(g, L, parent, zone, path, f)
|
||||
local key, a, b, step, name = f[1], f[2], f[3], f[4], f[5]
|
||||
local kind = type(a) == "string" and a or "number"
|
||||
local text = (kind ~= "number" and b or name) or label_of(key)
|
||||
local gid = (path or "root") .. "." .. key
|
||||
local target = group_of(L.draft, path)
|
||||
local kids = {}
|
||||
if kind ~= "select" then
|
||||
kids[1] = fg.label(parent, text, "small", ROW_H, { width = LABEL_W, shrink = 0, fit = true })
|
||||
end
|
||||
if kind == "number" then
|
||||
local o = { min = a, max = b, step = step }
|
||||
local fkey = "lab:" .. gid
|
||||
local f_state = field.get(g, fkey)
|
||||
f_state.text = fmt(target[key], step)
|
||||
local ctl = {}
|
||||
kids[#kids + 1] = fg.made({ grow = 1, height = ROW_H, padding_right = 10 }, function(p, x, y, w, h)
|
||||
ctl.slider = slider.new(g, p, x + 6, y, w - 16, h, { min = a, max = b, step = step, value = target[key],
|
||||
id = "slider:" .. gid, label = text, zone = zone,
|
||||
on_change = function(v)
|
||||
target[key] = v
|
||||
if ctl.input then
|
||||
ctl.syncing = true
|
||||
ctl.input:set_text(fmt(v, step))
|
||||
f_state.text = fmt(v, step)
|
||||
ctl.syncing = false
|
||||
end
|
||||
changed(L)
|
||||
end })
|
||||
end)
|
||||
kids[#kids + 1] = fg.made({ width = NUM_W, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
|
||||
ctl.input = field.new(g, p, fkey, x, y, w, h, { font = "mono", px = 13, allowed = "[%d%.%-]", max_len = 9,
|
||||
min = o.min, max = o.max,
|
||||
on_change = function(t)
|
||||
local v = tonumber(t)
|
||||
if ctl.syncing or not v then
|
||||
return
|
||||
end
|
||||
v = math.max(o.min, math.min(o.max, v))
|
||||
target[key] = v
|
||||
ctl.slider:set(v)
|
||||
changed(L)
|
||||
end })
|
||||
end)
|
||||
elseif kind == "color" then
|
||||
local fkey = "lab:" .. gid
|
||||
field.get(g, fkey).text = target[key]
|
||||
local sw
|
||||
kids[#kids + 1] = fg.made({ width = 26, height = 22, shrink = 0 }, function(p, x, y, w, h)
|
||||
sw = ui.rect(p, x, y, w, h, "line_hi")
|
||||
-- (the setting's own color, not a theme color: the swatch shows the data)
|
||||
local c = terrain.hex_to_rgb(target[key])
|
||||
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
|
||||
end)
|
||||
kids[#kids + 1] = fg.made({ width = 100, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
|
||||
field.new(g, p, fkey, x + 10, y, w - 10, h, { font = "mono", px = 13, allowed = "[#%x]", max_len = 7,
|
||||
on_change = function(t)
|
||||
if t:match("^#%x%x%x%x%x%x$") then
|
||||
target[key] = t:lower()
|
||||
local c = terrain.hex_to_rgb(t)
|
||||
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
|
||||
changed(L)
|
||||
end
|
||||
end })
|
||||
end)
|
||||
elseif kind == "bool" then
|
||||
local on = target[key] == true
|
||||
kids[#kids + 1] = small_button(parent, on and "On" or "Off", "lab:bool:" .. gid, function()
|
||||
target[key] = not target[key]
|
||||
rebuild(g)
|
||||
end, { g = g, on = on, variant = on and nil or "ghost", style = { width = 72 } })
|
||||
else -- the technique
|
||||
local row = {}
|
||||
for _, t in ipairs(terrain.TECHNIQUES) do
|
||||
local cur = target[key] == t
|
||||
row[#row + 1] = small_button(parent, t, "lab:tech:" .. t, function()
|
||||
target[key] = t
|
||||
rebuild(g)
|
||||
end, { g = g, variant = cur and "primary" or nil })
|
||||
end
|
||||
return fg.col({ gap = 6 }, {
|
||||
fg.label(parent, text, "small", 20, { fit = true }),
|
||||
fg.box({ wrap = true, gap = 6 }, row),
|
||||
})
|
||||
end
|
||||
return fg.row({ height = ROW_H, align = "center" }, kids)
|
||||
end
|
||||
|
||||
-- The settings, grouped like the web page, in a scroll area at (x, y), w x h.
|
||||
local function settings_list(g, L, parent, x, y, w, h)
|
||||
local area = scroll.area(g, parent, x, y, w, h, "moonlab")
|
||||
local p = area.content
|
||||
local groups = {}
|
||||
for _, sec in ipairs(SCHEMA) do
|
||||
local rows = { fg.label(p, sec.title, "h3", 22) }
|
||||
if sec.note then
|
||||
rows[#rows + 1] = fg.label(p, sec.note, "small", 20, { fit = true })
|
||||
end
|
||||
for _, f in ipairs(sec.fields) do
|
||||
rows[#rows + 1] = setting_row(g, L, p, area.view, sec.path, f)
|
||||
end
|
||||
groups[#groups + 1] = fg.col({ gap = 4 }, rows)
|
||||
end
|
||||
local tree = fg.col({ gap = 18, width = w, padding = { 4, 14, 16, 2 } }, groups)
|
||||
flex.solve(tree, 0, 0)
|
||||
fg.build(tree, p)
|
||||
area:finish(tree.h)
|
||||
end
|
||||
|
||||
-- The fixed bar over the settings: seed, presets, Defaults / Reset / status / Save.
|
||||
-- Returns its height.
|
||||
local function toolbar(g, L, parent, x, y, w)
|
||||
local st = g.state or {}
|
||||
field.get(g, "lab-seed").text = L.seed
|
||||
local function seed_to(s)
|
||||
L.seed = s
|
||||
field.get(g, "lab-seed").text = s
|
||||
rebuild(g)
|
||||
end
|
||||
local presets = { small_button(parent, SEED, "lab:seed:" .. SEED, function() seed_to(SEED) end, { g = g }) }
|
||||
for i, m in ipairs(st.moons or {}) do
|
||||
if i > 3 then
|
||||
break
|
||||
end
|
||||
local id = tostring(m.id)
|
||||
presets[#presets + 1] = small_button(parent, m.name or id, "lab:seed:" .. id, function() seed_to(id) end,
|
||||
{ g = g, style = { max_width = 160 } })
|
||||
end
|
||||
local status_w = 170
|
||||
local tree = fg.col({ width = w, gap = 10 }, {
|
||||
fg.label(parent, "Seed (moon id)", "cap", 14),
|
||||
fg.row({ gap = 8, height = S.button_sm_h, align = "center" }, {
|
||||
fg.made({ grow = 1, height = S.button_sm_h }, function(p, bx, by, bw, bh)
|
||||
field.new(g, p, "lab-seed", bx, by, bw, bh, { font = "mono", px = 13, hint = SEED, max_len = 64,
|
||||
on_change = function(t)
|
||||
L.seed = t ~= "" and t or SEED
|
||||
L.pending = true
|
||||
end })
|
||||
end),
|
||||
small_button(parent, "New seed", "lab:seed-new", function()
|
||||
seed_to(string.format("%08x", math.random(0, 0x7fffffff)))
|
||||
end, { g = g }),
|
||||
}),
|
||||
fg.box({ wrap = true, gap = 6 }, presets),
|
||||
fg.row({ gap = 8, height = S.button_h, align = "center" }, {
|
||||
small_button(parent, "Defaults", "lab:defaults", function()
|
||||
L.draft = clone(terrain.DEFAULTS)
|
||||
rebuild(g)
|
||||
end, { g = g }),
|
||||
small_button(parent, "Reset", "lab:reset", function()
|
||||
L.draft = clone(L.saved)
|
||||
rebuild(g)
|
||||
end, { g = g }),
|
||||
fg.made({ grow = 1, height = 20, shrink = 1 }, function(p, bx, by, bw, bh)
|
||||
L.w.status = ui.text(p, "", "small", bx + bw - 8, by + bh / 2, gui.PIVOT_E)
|
||||
L.w.status_w = math.min(status_w, bw - 8)
|
||||
show_status(L)
|
||||
end),
|
||||
fg.button(parent, "Save", { variant = "primary" }, function(b, nodes)
|
||||
button.bind(g, b, nodes, { id = "lab:save", on_click = function() save(g) end })
|
||||
end, { width = 110 }),
|
||||
}),
|
||||
})
|
||||
flex.solve(tree, x, y)
|
||||
fg.build(tree, parent)
|
||||
return tree.h
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- build
|
||||
|
||||
-- The block's inputs (core/main keeps it while they stay the same).
|
||||
function M.sig(g)
|
||||
local L = lab(g)
|
||||
return { L.rev, L.loaded }
|
||||
end
|
||||
|
||||
function M.build(g, parent, x, y, w, h)
|
||||
local L = lab(g)
|
||||
L.w = {}
|
||||
local gap = S.gap
|
||||
local cw = math.max(CTRL_MIN, math.min(CTRL_MAX, math.floor(w * 0.4)))
|
||||
local mw = w - cw - gap
|
||||
-- The moon.
|
||||
local mp = panel.new(parent, x, y, mw, h)
|
||||
local size = math.max(100, math.min(mw - PAD * 2, h - PAD * 3 - STATS_H))
|
||||
local vx = math.floor((mw - size) / 2)
|
||||
local vp = ui.rect(mp.body, vx, PAD, size, size, "viewport")
|
||||
L.vp = vp
|
||||
moon_view.show(L.seed, vp)
|
||||
L.w.stats = ui.text(mp.body, "", "num_small", vx, PAD + size + PAD / 2 + STATS_H / 2)
|
||||
L.w.stats_w = size
|
||||
L.shown_build = nil
|
||||
ui.text(mp.body, "Drag to rotate · scroll to zoom", "cap", vx + size, PAD + size + PAD / 2 + STATS_H / 2, gui.PIVOT_E)
|
||||
-- The settings.
|
||||
local cp = panel.new(parent, x + mw + gap, y, cw, h)
|
||||
local inner = cw - PAD * 2
|
||||
local th = toolbar(g, L, cp.body, PAD, PAD, inner)
|
||||
local ly = PAD + th + 12
|
||||
ui.rect(cp.body, PAD, ly - 6, inner, 1, "line")
|
||||
settings_list(g, L, cp.body, PAD, ly, inner + PAD - 4, h - ly - PAD)
|
||||
end
|
||||
|
||||
-- Every frame while the lab is shown: hand the moon pending settings when it's free, and
|
||||
-- show the last build.
|
||||
function M.update(g, dt)
|
||||
local L = g.lab
|
||||
if not L or not L.vp then
|
||||
return
|
||||
end
|
||||
if L.pending and not moon_view.building then
|
||||
L.pending = false
|
||||
moon_view.settings = clone(L.draft)
|
||||
moon_view.show(L.seed, L.vp)
|
||||
moon_view.building = true -- (until moon.script looks: one handed over at a time)
|
||||
end
|
||||
local b = moon_view.last_build
|
||||
if b and b ~= L.shown_build and L.w and L.w.stats then
|
||||
L.shown_build = b
|
||||
local verts = tostring(b.vertices):reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "")
|
||||
ui.style_text(L.w.stats, "num_small", string.format("%s · %s vertices · %.0f ms", b.technique, verts, b.ms))
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -273,6 +273,35 @@ function M.grade(g)
|
||||
return st.moonGrade
|
||||
end
|
||||
|
||||
-- What the rows show (widgets/moon_row: name, grade, rating, status, warning, ready
|
||||
-- badge, selection), both lists: the moon list's block signature has this instead of the
|
||||
-- moons themselves, so a change the rows don't show (a well's projection, a drill's end)
|
||||
-- keeps the list.
|
||||
function M.rows_sig(self)
|
||||
local st, v = self.state, self.view
|
||||
local out, n = {}, 0
|
||||
for _, list in ipairs({ st.moons, st.past }) do
|
||||
for _, m in ipairs(list) do
|
||||
local dry = not m.finished and #(m.wells or {}) > 0
|
||||
if dry then
|
||||
for _, w in ipairs(m.wells) do
|
||||
if w.supply > 0 then
|
||||
dry = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
n = n + 1
|
||||
out[n] = { m.id, m.name, m.grade or false, m.rating or false, m.finished or false, m.mined or false,
|
||||
m.finishedOn or false, m.finishedAt or false, dry, m.drills or 0, m.over or false,
|
||||
not m.finished and ready.drills(self, m.id) or m.ready or 0, (m.selected and v.section == "moons") or false }
|
||||
end
|
||||
n = n + 1
|
||||
out[n] = "|"
|
||||
end
|
||||
return blocks.sig(out)
|
||||
end
|
||||
|
||||
function M.build(self, H, parent, x, y, w, h)
|
||||
local st = self.state
|
||||
local v = self.view
|
||||
|
||||
+318
-149
@@ -19,6 +19,7 @@ local fg = require("modules.flex_gui")
|
||||
local images = require("modules.images")
|
||||
local test = require("modules.test")
|
||||
local predict = require("modules.predict")
|
||||
local blocks = require("modules.blocks")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
@@ -302,42 +303,95 @@ local function fit(p, str, style, opts)
|
||||
return fg.label(p, str, style, 16, opts)
|
||||
end
|
||||
|
||||
-- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a
|
||||
-- cycle gets here, or why it can't (no idle drills, no room).
|
||||
local function deploy_slot(g, st, well, groups, H)
|
||||
-- Each part of the moon (its heading, a well's card, the multi-well card, the tray) is a
|
||||
-- kept block (modules/blocks), built at its own origin and put in place: a deploy rebuilds
|
||||
-- the cards it changed, not the panel. What a part shows is worked out first (its model,
|
||||
-- from the state and our clock) and is its block's signature; the build draws only from
|
||||
-- the model and the state, so a kept part shows what a rebuilt one would.
|
||||
|
||||
-- A kept part of the moon (key, sig: its model) at (x, y) of parent: fn(root) builds it
|
||||
-- at root's origin and returns its height (also when it's kept). Parts nest: a card's
|
||||
-- slots are parts of their own, so a card that changes keeps the slots that didn't.
|
||||
local function part(g, key, sig, parent, x, y, fn)
|
||||
local b, kept = blocks.open(g, key, sig, parent)
|
||||
local h
|
||||
if kept then
|
||||
h = blocks.close(g, b)
|
||||
else
|
||||
h = blocks.close(g, b, fn(b.root))
|
||||
end
|
||||
gui.set_position(b.root, vec(x, -y))
|
||||
return h
|
||||
end
|
||||
|
||||
-- The busy and ready-pop marks (core/hooks: g.inflight, g.ready_at) of these action keys,
|
||||
-- for a part's signature.
|
||||
local function marks(g, keys)
|
||||
local out = {}
|
||||
for i, k in ipairs(keys) do
|
||||
local r = g.ready_at and g.ready_at[k]
|
||||
out[i] = (g.inflight[k] and "b" or "") .. (r and tostring(r.t) or "")
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- What a well's deploy slot shows (the group it sends and its two lines).
|
||||
local function deploy_view(g, st, well, groups)
|
||||
local grp, here, amount, explicit = deploy_choice(g, st, well, groups)
|
||||
local ok = grp ~= nil and here > EPS
|
||||
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
|
||||
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not ok })
|
||||
local title, sub, sub_color
|
||||
local v = { ok = ok, explicit = explicit or false, key = grp and grp.key or false,
|
||||
name = grp and grp.tool.name or false, tool = grp and grp.tool.id or false }
|
||||
if #groups == 0 then
|
||||
title, sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
|
||||
v.title, v.sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
|
||||
elseif not grp then
|
||||
title, sub = "No room here", "The drills on it take all it has"
|
||||
v.title, v.sub = "No room here", "The drills on it take all it has"
|
||||
elseif not ok then
|
||||
title, sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
|
||||
v.title, v.sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
|
||||
else
|
||||
local t = grp.tool
|
||||
local reach = reach_short(st, t.reach)
|
||||
title = "+ Deploy " .. M.short_name(t)
|
||||
sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
|
||||
v.title = "+ Deploy " .. M.short_name(t)
|
||||
v.sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
|
||||
if here < amount - EPS then
|
||||
sub, sub_color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
|
||||
v.sub, v.color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
|
||||
end
|
||||
end
|
||||
return v
|
||||
end
|
||||
|
||||
-- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a
|
||||
-- cycle gets here, or why it can't (no idle drills, no room). v: deploy_view. It sends
|
||||
-- the group's first idle copy at the click (a kept slot outlives the drill it was built
|
||||
-- with); its test id is the well's: act:stake?target=<well>.
|
||||
local function deploy_slot(g, well, v, mk, H)
|
||||
return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
|
||||
part(g, "mo:deploy:" .. well.id, blocks.sig(w, v, mk), p0, x0, y0, function(p)
|
||||
local x, y = 0, 0
|
||||
-- (off: a plain line and no fill, so it reads as greyed beside a live slot)
|
||||
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not v.ok,
|
||||
edge = not v.ok and "line" or nil, fill = not v.ok and { "cyan", 0 } or nil })
|
||||
local tree = col({ width = w, height = SLOT_H, padding = { 0, 10 }, justify = "center", align = "center", gap = 2 }, {
|
||||
fit(b, title, { font = "sans_semi", px = 13, color = ok and "cyan" or "muted", tracking = 0.06, upper = true }),
|
||||
fit(b, sub, { font = "sans", px = 12, color = sub_color or "muted" }),
|
||||
fit(b, v.title, { font = "sans_semi", px = 13, color = v.ok and "cyan" or "muted", tracking = 0.06, upper = true }),
|
||||
fit(b, v.sub, { font = "sans", px = 12, color = v.color or "muted" }),
|
||||
})
|
||||
fg.build(flex.solve(tree, 0, 0), b)
|
||||
if ok then
|
||||
local params = { tool = grp.tool.id, target = well.id }
|
||||
H.action(g, b, bn, "stake", params)
|
||||
tooltip.add(b, "Deploy " .. (grp.tool.name or "a drill") .. (explicit and "" or " (the smallest idle drill with room here)"))
|
||||
if v.ok then
|
||||
local function params()
|
||||
for _, grp in ipairs(M.idle_groups(g.state)) do
|
||||
if grp.key == v.key then
|
||||
return { tool = grp.tool.id, target = well.id }
|
||||
end
|
||||
end
|
||||
return { tool = v.tool, target = well.id } -- (gone since: the server says why)
|
||||
end
|
||||
H.action(g, b, bn, "stake", params, nil, nil, "act:stake?target=" .. well.id)
|
||||
tooltip.add(b, "Deploy " .. (v.name or "a drill") .. (v.explicit and "" or " (the smallest idle drill with room here)"))
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
-- What a drill's slot shows besides the drill d: dm = { name, due, reach } (drill_models).
|
||||
-- A drill's slot (SLOT_H tall; slides in when it was just deployed): its short name and
|
||||
-- time left over its timer bar and what it gets; done (ready, or its end passed on our
|
||||
-- clock): gold, "+X ready", Claim (scope: claim-well or claim-wide) and, once unlocked,
|
||||
@@ -345,11 +399,13 @@ end
|
||||
-- the plain action ids). wait: the group's next drill to finish; where its Claim and
|
||||
-- Redeploy will show is registered as "wait:claim?..." (like H.claim_wait's waiting
|
||||
-- button; not focusable), for the test driver.
|
||||
local function drill_slot(g, st, d, scope, can_redeploy, n, wait, H, enters)
|
||||
local tool = find(st.tools, d.toolId)
|
||||
local due = d.ready or (d["end"] or math.huge) <= H.now(g)
|
||||
local reach = reach_short(st, d.reach)
|
||||
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
|
||||
local function drill_slot(g, st, d, dm, scope, can_redeploy, n, wait, mk, H, enters)
|
||||
local due = dm.due
|
||||
local reach = dm.reach or nil
|
||||
return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
|
||||
-- (a part of its own: a card that changes keeps the slots that didn't)
|
||||
part(g, "mo:slot:" .. d.toolId, blocks.sig(w, d, dm, scope, can_redeploy, n, wait, mk), p0, x0, y0, function(p)
|
||||
local x, y = 0, 0
|
||||
local slot = ui.rect(p, x, y, w, SLOT_H, "none")
|
||||
local body = ui.rect(slot, 0, 0, w, SLOT_H, due and "ready" or "cyan", due and 0.08 or 0.04)
|
||||
ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil)
|
||||
@@ -408,7 +464,7 @@ local function drill_slot(g, st, d, scope, can_redeploy, n, wait, H, enters)
|
||||
end
|
||||
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
|
||||
row({ height = 16, align = "center" }, {
|
||||
fit(body, M.short_name(tool), NAME13),
|
||||
fit(body, dm.name, NAME13),
|
||||
reach and space(5) or nil, reach and fit(body, "· " .. reach, S12) or nil,
|
||||
fg.fill(), space(6), right,
|
||||
}),
|
||||
@@ -439,11 +495,36 @@ local function drill_slot(g, st, d, scope, can_redeploy, n, wait, H, enters)
|
||||
row_c.enter(g, "drill:" .. d.toolId, body, w, SLOT_H, { base = gui.get_color(body) })
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
-- The slot of the idle drills that last mined the well (well.redrill): how many, and
|
||||
-- Redrill (sends them out again) once unlocked and the well has supply.
|
||||
local function idle_slot(g, st, well, H)
|
||||
-- The drill of a list that finishes next (not done yet), or nil.
|
||||
local function next_drill(g, drills)
|
||||
local now, pick = g.h.now(g), nil
|
||||
for _, d in ipairs(drills or {}) do
|
||||
if not d.ready and d["end"] and d["end"] > now and (not pick or d["end"] < pick["end"]) then
|
||||
pick = d
|
||||
end
|
||||
end
|
||||
return pick
|
||||
end
|
||||
|
||||
-- The drills of a group (a well's, or the multi-well ones) as their slots show them:
|
||||
-- { name, due, reach, n (its place among the ready ones, 0: not ready), wait }.
|
||||
local function drill_models(g, st, drills, H)
|
||||
local now, next_d = H.now(g), next_drill(g, drills)
|
||||
local out, n = {}, 0
|
||||
for i, d in ipairs(drills or {}) do
|
||||
local due = d.ready or (d["end"] or math.huge) <= now
|
||||
n = n + (due and 1 or 0)
|
||||
out[i] = { name = M.short_name(find(st.tools, d.toolId)), due = due and true or false,
|
||||
reach = reach_short(st, d.reach) or false, n = due and n or 0, wait = d == next_d }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- What the idle slot of a well shows (well.redrill > 0).
|
||||
local function idle_view(st, well)
|
||||
local n = well.redrill or 0
|
||||
local names = {}
|
||||
for _, t in ipairs(st.tools or {}) do
|
||||
@@ -454,21 +535,27 @@ local function idle_slot(g, st, well, H)
|
||||
local who = #names > 0 and (table.concat(names, ", ") .. (n > #names and (" +" .. (n - #names)) or "")) or ui.plural(n, "drill")
|
||||
-- (no Redrill while the drills on the well take all it has: the server refuses it)
|
||||
local full = well.supply > 0 and (well.remain or 1) <= 1e-9
|
||||
local can = st.unlocked.redrill and well.supply > 0 and not full
|
||||
return { n = n, who = who, full = full, can = (st.unlocked.redrill and well.supply > 0 and not full) and true or false }
|
||||
end
|
||||
|
||||
-- The slot of the idle drills that last mined the well (well.redrill): how many, and
|
||||
-- Redrill (sends them out again) once unlocked and the well has supply. v: idle_view.
|
||||
local function idle_slot(g, well, v, H)
|
||||
local n = v.n
|
||||
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
|
||||
local body = ui.rect(p, x, y, w, SLOT_H, "cyan", 0.02)
|
||||
ui.outline(body, 0, 0, w, SLOT_H, "line_hi")
|
||||
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
|
||||
row({ height = 16, align = "center" }, {
|
||||
fit(body, who, { font = "sans_semi", px = 13, color = "text_2" }), fg.fill(), space(6),
|
||||
fit(body, v.who, { font = "sans_semi", px = 13, color = "text_2" }), fg.fill(), space(6),
|
||||
fg.label(body, "Idle", { font = "sans_semi", px = 12, color = "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 }),
|
||||
}),
|
||||
can and fg.button(body, "Redrill" .. (n > 1 and (" " .. n) or ""), { small = true, variant = "redrill", h = SLOT_BTN_H }, function(b, bn)
|
||||
v.can and fg.button(body, "Redrill" .. (n > 1 and (" " .. n) or ""), { small = true, variant = "redrill", h = SLOT_BTN_H }, function(b, bn)
|
||||
H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
|
||||
tooltip.add(b, "Send the drills that last mined this well out again")
|
||||
end, { height = SLOT_BTN_H }) or row({ height = SLOT_BTN_H, align = "center" }, {
|
||||
fit(body, well.supply <= 0 and "Last mined here · the well is empty"
|
||||
or (full and "Last mined here · the drills on it take the rest" or "Last mined here"), S11),
|
||||
or (v.full and "Last mined here · the drills on it take the rest" or "Last mined here"), S11),
|
||||
}),
|
||||
})
|
||||
fg.build(flex.solve(tree, 0, 0), body)
|
||||
@@ -488,40 +575,53 @@ local function well_note(c, well)
|
||||
end
|
||||
end
|
||||
|
||||
-- The drill of a list that finishes next (not done yet), or nil.
|
||||
local function next_drill(g, drills)
|
||||
local now, pick = g.h.now(g), nil
|
||||
for _, d in ipairs(drills or {}) do
|
||||
if not d.ready and d["end"] and d["end"] > now and (not pick or d["end"] < pick["end"]) then
|
||||
pick = d
|
||||
end
|
||||
end
|
||||
return pick
|
||||
end
|
||||
|
||||
-- One well's card (a grid cell; its node selects the well when clicked).
|
||||
local function well_card(g, c, st, moon, well, max, groups, H, enters)
|
||||
-- What a well's card shows (its block's signature with its rect): the well, its deploy
|
||||
-- slot, its drills' slots, its idle slot. slots: how many (card_h).
|
||||
local function card_model(g, st, well, max, groups, H)
|
||||
local dry = well.supply <= 0
|
||||
local selected = well.id == g.view.well
|
||||
local scope = { ["claim-well"] = well.id }
|
||||
local slots = {}
|
||||
if not dry then
|
||||
slots[#slots + 1] = deploy_slot(g, st, well, groups, H)
|
||||
end
|
||||
local n, next_d = 0, next_drill(g, well.drills)
|
||||
-- Tapped out once the ready drills are claimed (nothing left, or what is left is the
|
||||
-- other drills' on it, the server's `remain`): Claim only, no Redeploy.
|
||||
local tapped = well.supply - ready.amount(g, well, well.cap) <= 1e-9 or (well.remain or 1) <= 1e-9
|
||||
for _, d in ipairs(well.drills or {}) do
|
||||
local due = d.ready or (d["end"] or math.huge) <= H.now(g)
|
||||
n = n + (due and 1 or 0)
|
||||
slots[#slots + 1] = drill_slot(g, st, d, scope, not dry and not tapped and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters)
|
||||
local m = {
|
||||
well = well, max = max, dry = dry, selected = well.id == g.view.well,
|
||||
redeploy = (not dry and not tapped and st.unlocked.redeploy) and true or false,
|
||||
deploy = not dry and deploy_view(g, st, well, groups) or false,
|
||||
drills = drill_models(g, st, well.drills, H),
|
||||
idle = (well.redrill or 0) > 0 and idle_view(st, well) or false,
|
||||
}
|
||||
m.slots = (dry and 0 or 1) + #m.drills + (m.idle and 1 or 0)
|
||||
local scope = "claim?claim-well=" .. well.id
|
||||
m.marks = marks(g, { scope, scope .. "&redeploy=1", "stake?target=" .. well.id, "redrill?redrill-well=" .. well.id })
|
||||
return m
|
||||
end
|
||||
if (well.redrill or 0) > 0 then
|
||||
slots[#slots + 1] = idle_slot(g, st, well, H)
|
||||
|
||||
-- A card's height for its slots (its tree's, below: the head lines, then the slots).
|
||||
local CARD_HEAD = 18 + 7 + 6 + 4 + 16 + 8
|
||||
local function card_h(slots)
|
||||
return CARD_PAD * 2 + CARD_HEAD + (slots > 0 and (slots * SLOT_H + (slots - 1) * SLOT_GAP) or 0)
|
||||
end
|
||||
|
||||
-- One well's card, w x h at the origin of c (its node selects the well when clicked).
|
||||
-- m: card_model.
|
||||
local function well_card(g, c, st, moon, m, w, h, H)
|
||||
local well = m.well
|
||||
local dry = m.dry
|
||||
local scope = { ["claim-well"] = well.id }
|
||||
local enters = {}
|
||||
local slots = {}
|
||||
if m.deploy then
|
||||
slots[#slots + 1] = deploy_slot(g, well, m.deploy, m.marks, H)
|
||||
end
|
||||
for i, d in ipairs(well.drills or {}) do
|
||||
local dm = m.drills[i]
|
||||
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
|
||||
end
|
||||
if m.idle then
|
||||
slots[#slots + 1] = idle_slot(g, well, m.idle, H)
|
||||
end
|
||||
local sup = fit(c, ui.money(well.supply), { font = "mono", px = 13, color = dry and "muted" or "text" }, { shrink = 0 })
|
||||
sup.done = function(node) H.tick_num(g, node, "supply:" .. well.id, well.supply) end
|
||||
local list = col({ gap = SLOT_GAP }, slots)
|
||||
local kids = {
|
||||
row({ height = 18, align = "center" }, {
|
||||
made({ width = 8, height = 8, shrink = 0 }, function(q, x, y) ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1) end),
|
||||
@@ -529,48 +629,64 @@ local function well_card(g, c, st, moon, well, max, groups, H, enters)
|
||||
fg.fill(), space(6), sup,
|
||||
}),
|
||||
space(0, 7),
|
||||
made({ height = 6, shrink = 0 }, function(q, x, y, w)
|
||||
made({ height = 6, shrink = 0 }, function(q, x, y, bw)
|
||||
if dry then
|
||||
ui.rect(q, x, y, w, 6, "line")
|
||||
ui.rect(q, x, y, bw, 6, "line")
|
||||
else
|
||||
M.supply_bar(q, x, y, w, well, max)
|
||||
M.supply_bar(q, x, y, bw, well, m.max)
|
||||
end
|
||||
end),
|
||||
space(0, 4),
|
||||
fg.reserve({ height = 16 }, well_note(c, well)),
|
||||
space(0, 8),
|
||||
col({ gap = SLOT_GAP }, slots),
|
||||
list,
|
||||
}
|
||||
return col({ padding = CARD_PAD }, kids, { make = function(parent, x, y, w, h)
|
||||
local slot = ui.rect(parent, x, y, w, h, "none")
|
||||
local card = ui.rect(slot, 0, 0, w, h, "cyan", 0)
|
||||
row_c.bind(g, card, w, h, { selected = selected, id = "well:" .. well.id, label = well.resource,
|
||||
local tree = col({ padding = CARD_PAD }, kids, { make = function(parent, x, y, cw, ch)
|
||||
local slot = ui.rect(parent, x, y, cw, ch, "none")
|
||||
local card = ui.rect(slot, 0, 0, cw, ch, "cyan", 0)
|
||||
row_c.bind(g, card, cw, ch, { selected = m.selected, id = "well:" .. well.id, label = well.resource,
|
||||
on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
|
||||
ui.outline(card, 0, 0, w, h, selected and "cyan" or "line_hi", selected and 0.9 or nil)
|
||||
g.well_nodes[well.id] = card
|
||||
ui.outline(card, 0, 0, cw, ch, m.selected and "cyan" or "line_hi", m.selected and 0.9 or nil)
|
||||
blocks.put(g, "well_nodes", well.id, card)
|
||||
return card
|
||||
end })
|
||||
fg.build(flex.solve(tree, 0, 0, w, h), c)
|
||||
if math.abs(list.y + list.h + CARD_PAD - card_h(m.slots)) > 0.5 then
|
||||
print("ERROR:MM: moons: a well card's height is " .. (list.y + list.h + CARD_PAD) .. ", card_h says " .. card_h(m.slots))
|
||||
end
|
||||
for _, f in ipairs(enters) do
|
||||
f()
|
||||
end
|
||||
end
|
||||
|
||||
-- The multi-well drills (moon.wide: reach > 1, claimed together with claim-wide): a card
|
||||
-- across the grid, its slots in the cards' columns.
|
||||
local function wide_card(g, c, st, moon, inner, H, enters)
|
||||
-- The multi-well drills (moon.wide: reach > 1, claimed together with claim-wide): what its
|
||||
-- card shows.
|
||||
local function wide_model(g, st, moon, H)
|
||||
local wide = moon.wide
|
||||
local scope = { ["claim-wide"] = moon.id }
|
||||
local slots, n, next_d = {}, 0, next_drill(g, wide.drills)
|
||||
-- (the moon tapped out once these are claimed: no Redeploy)
|
||||
local left = 0
|
||||
for _, w in ipairs(moon.wells or {}) do
|
||||
left = left + w.supply
|
||||
end
|
||||
local tapped = left - ready.amount(g, wide, wide.cap) <= 1e-9
|
||||
for _, d in ipairs(wide.drills or {}) do
|
||||
local due = d.ready or (d["end"] or math.huge) <= H.now(g)
|
||||
n = n + (due and 1 or 0)
|
||||
slots[#slots + 1] = drill_slot(g, st, d, scope, not tapped and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters)
|
||||
local scope = "claim?claim-wide=" .. moon.id
|
||||
return { wide = wide, redeploy = (not tapped and st.unlocked.redeploy) and true or false,
|
||||
drills = drill_models(g, st, wide.drills, H), marks = marks(g, { scope, scope .. "&redeploy=1" }) }
|
||||
end
|
||||
|
||||
-- Its card across the grid (inner wide, at the origin of c), its slots in the cards'
|
||||
-- columns. Returns its height.
|
||||
local function wide_card(g, c, st, moon, m, inner, H)
|
||||
local wide = m.wide
|
||||
local scope = { ["claim-wide"] = moon.id }
|
||||
local enters = {}
|
||||
local slots = {}
|
||||
for i, d in ipairs(wide.drills or {}) do
|
||||
local dm = m.drills[i]
|
||||
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
|
||||
end
|
||||
local sw = inner - CARD_PAD * 2
|
||||
return col({ width = inner, padding = CARD_PAD }, {
|
||||
local tree = col({ width = inner, padding = CARD_PAD }, {
|
||||
row({ height = 18, align = "center" }, {
|
||||
fg.label(c, "Multi-well", { font = "sans_semi", px = 15, color = "text" }, 18), space(10),
|
||||
fit(c, ui.money(wide.cap or 0) .. " left on the moon", S12), fg.fill(),
|
||||
@@ -582,28 +698,16 @@ local function wide_card(g, c, st, moon, inner, H, enters)
|
||||
ui.outline(card, 0, 0, w, h, "line_hi")
|
||||
return card
|
||||
end })
|
||||
fg.build(flex.solve(tree, 0, 0), c)
|
||||
for _, f in ipairs(enters) do
|
||||
f()
|
||||
end
|
||||
return tree.h
|
||||
end
|
||||
|
||||
-- The tray of idle drills: a tile per group (picture, short name, ×count, what a cycle
|
||||
-- gets); a tile toggles the pick the deploy slots send.
|
||||
local function tray(g, c, st, groups, inner, H)
|
||||
local head = row({ height = 16, align = "center" }, {
|
||||
fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0),
|
||||
#groups > 0 and fit(c, "Click one to deploy it to the selected well", "small") or nil,
|
||||
})
|
||||
if #groups == 0 then
|
||||
return col({ width = inner }, {
|
||||
head, space(0, 8),
|
||||
row({ height = 16, align = "center" }, {
|
||||
fg.label(c, "No idle drills.", "small", 16),
|
||||
st.unlocked.store and space(5) or nil,
|
||||
st.unlocked.store and made({ width = 160, height = 16, shrink = 0 }, function(p, x, y)
|
||||
H.text_link(g, p, "Buy one in the store", { font = "sans", px = 13 }, x, y + 8, { section = "store", moon = g.view.moon })
|
||||
end) or nil,
|
||||
}),
|
||||
})
|
||||
end
|
||||
-- The well a tile deploys to: the selected one, else the first it has room on.
|
||||
-- What the tray of idle drills shows: a tile per group (the well it deploys to: the
|
||||
-- selected one, else the first it has room on).
|
||||
local function tray_model(g, st, groups)
|
||||
local moon = st.moon
|
||||
local function target(t)
|
||||
local wells = moon and moon.wells or {}
|
||||
@@ -618,40 +722,78 @@ local function tray(g, c, st, groups, inner, H)
|
||||
end
|
||||
end
|
||||
end
|
||||
local tiles = {}
|
||||
for _, grp in ipairs(groups) do
|
||||
local tiles, keys = {}, {}
|
||||
for i, grp in ipairs(groups) do
|
||||
local t = grp.tool
|
||||
local well = target(t)
|
||||
local here = well and gets_here(st, well, t) or 0
|
||||
local ok = well ~= nil and well.supply > 0 and here > EPS
|
||||
local picked = false
|
||||
tiles[#tiles + 1] = made({ height = TILE_H }, function(p, x, y, w)
|
||||
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = TILE_H, disabled = not ok })
|
||||
local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg")
|
||||
if t.image then
|
||||
images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), t.image)
|
||||
tiles[i] = { key = grp.key, name = M.short_name(t), count = grp.count, tool = t.id, tname = t.name or false, image = t.image or false,
|
||||
gets = "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach_short(st, t.reach) and (" · " .. reach_short(st, t.reach)) or ""),
|
||||
well = well and well.id or false, resource = well and well.resource or false,
|
||||
ok = (well ~= nil and well.supply > 0 and here > EPS) and true or false }
|
||||
if well then
|
||||
keys[#keys + 1] = "stake?target=" .. well.id .. "&tool=" .. t.id
|
||||
end
|
||||
local reach = reach_short(st, t.reach)
|
||||
local tree = col({ width = w, height = TILE_H, padding = { 8, 8, 8, TILE_H }, gap = 4 }, {
|
||||
row({ height = 16, align = "center" }, {
|
||||
fit(b, M.short_name(t), NAME13), fg.fill(), space(4),
|
||||
fg.label(b, "×" .. grp.count, { font = "mono", px = 12, color = picked and "cyan" or "text_2" }, 16, { shrink = 0 }),
|
||||
}),
|
||||
fit(b, "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or ""), { font = "mono", px = 11, color = "muted" }),
|
||||
end
|
||||
return { tiles = tiles, store = st.unlocked.store and true or false, moon = g.view.moon, marks = marks(g, keys) }
|
||||
end
|
||||
|
||||
-- The tray (inner wide, at the origin of c): a tile per group (picture, short name,
|
||||
-- ×count, what a cycle gets); a tile deploys one of them. m: tray_model. Returns its
|
||||
-- height.
|
||||
local function tray(g, c, m, inner, H)
|
||||
local head = row({ height = 16, align = "center" }, {
|
||||
fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0),
|
||||
#m.tiles > 0 and fit(c, "Click one to deploy it to the selected well", "small") or nil,
|
||||
})
|
||||
fg.build(flex.solve(tree, 0, 0), b)
|
||||
local tree
|
||||
if #m.tiles == 0 then
|
||||
tree = col({ width = inner }, {
|
||||
head, space(0, 8),
|
||||
row({ height = 16, align = "center" }, {
|
||||
fg.label(c, "No idle drills.", "small", 16),
|
||||
m.store and space(5) or nil,
|
||||
m.store and made({ width = 160, height = 16, shrink = 0 }, function(p, x, y)
|
||||
H.text_link(g, p, "Buy one in the store", { font = "sans", px = 13 }, x, y + 8, { section = "store", moon = m.moon })
|
||||
end) or nil,
|
||||
}),
|
||||
})
|
||||
else
|
||||
local tiles = {}
|
||||
for _, tv in ipairs(m.tiles) do
|
||||
local picked = false
|
||||
tiles[#tiles + 1] = made({ height = TILE_H }, function(p0, x0, y0, w)
|
||||
part(g, "mo:tile:" .. tv.key, blocks.sig(w, tv, m.marks), p0, x0, y0, function(p)
|
||||
local x, y = 0, 0
|
||||
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = TILE_H, disabled = not tv.ok })
|
||||
local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg")
|
||||
if tv.image then
|
||||
images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), tv.image)
|
||||
end
|
||||
local tt = col({ width = w, height = TILE_H, padding = { 8, 8, 8, TILE_H }, gap = 4 }, {
|
||||
row({ height = 16, align = "center" }, {
|
||||
fit(b, tv.name, NAME13), fg.fill(), space(4),
|
||||
fg.label(b, "×" .. tv.count, { font = "mono", px = 12, color = picked and "cyan" or "text_2" }, 16, { shrink = 0 }),
|
||||
}),
|
||||
fit(b, tv.gets, { font = "mono", px = 11, color = "muted" }),
|
||||
})
|
||||
fg.build(flex.solve(tt, 0, 0), b)
|
||||
if picked then
|
||||
ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9)
|
||||
end
|
||||
if ok then
|
||||
tooltip.add(b, "Deploy " .. (t.name or "a drill") .. " to the " .. well.resource .. " well")
|
||||
H.action(g, b, bn, "stake", { tool = t.id, target = well.id })
|
||||
if tv.ok then
|
||||
tooltip.add(b, "Deploy " .. (tv.tname or "a drill") .. " to the " .. tv.resource .. " well")
|
||||
H.action(g, b, bn, "stake", { tool = tv.tool, target = tv.well })
|
||||
else
|
||||
tooltip.add(b, (t.name or "Drill") .. (well and (" · no room on the " .. well.resource .. " well") or " · no room on this moon"))
|
||||
tooltip.add(b, (tv.tname or "Drill") .. (tv.well and (" · no room on the " .. tv.resource .. " well") or " · no room on this moon"))
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
return col({ width = inner }, { head, space(0, 8), flex.grid(inner, TILE_MIN, 8, tiles) })
|
||||
tree = col({ width = inner }, { head, space(0, 8), flex.grid(inner, TILE_MIN, 8, tiles) })
|
||||
end
|
||||
fg.build(flex.solve(tree, 0, 0), c)
|
||||
return tree.h
|
||||
end
|
||||
|
||||
|
||||
@@ -683,13 +825,10 @@ local function rating_chip(c, r)
|
||||
end)
|
||||
end
|
||||
|
||||
-- The moon: heading (id, name, class and rating, description; Claim at the right), the
|
||||
-- well cards, the multi-well card, the tray of idle drills, and selling the moon (once
|
||||
-- the store is open). min_h tall at least. Returns its bottom.
|
||||
local function moon_view(g, c, px, py, inner, min_h, H)
|
||||
local st = g.state
|
||||
local moon = st.moon
|
||||
|
||||
-- The heading (id, name, class and rating, description; Claim at the right), inner wide
|
||||
-- at the origin of c, 80 tall at least. Returns its height.
|
||||
local function heading(g, c, moon, n_ready, inner, H)
|
||||
-- The class line ("<type> class"), but not for moons named after their type (from
|
||||
-- before moon names), like the web.
|
||||
local has_class = moon.kind and moon.kind ~= "" and moon.kind ~= moon.name
|
||||
@@ -713,7 +852,6 @@ local function moon_view(g, c, px, py, inner, min_h, H)
|
||||
head[#head + 1] = space(0, 4)
|
||||
end
|
||||
-- Claim, at the top right (out of the flow: the heading's text keeps clear of it).
|
||||
local n_ready = ready.drills(g, moon.id)
|
||||
if moon.ready > 0 then
|
||||
head[#head + 1] = made({ position = "absolute", top = 26, right = 0 }, function(p, x, y)
|
||||
local b, nodes, bw = ui.button(p, "Claim " .. ui.plural(n_ready, "drill"), x, y, { primary = true })
|
||||
@@ -727,9 +865,31 @@ local function moon_view(g, c, px, py, inner, min_h, H)
|
||||
done = n_ready > 0 and ("Claim " .. ui.plural(n_ready, "drill")) or nil })
|
||||
end)
|
||||
end
|
||||
local tree = col({ width = inner, min_height = 80, align = "start" }, head)
|
||||
fg.build(flex.solve(tree, 0, 0), c)
|
||||
return tree.h
|
||||
end
|
||||
|
||||
-- The cards: a well each (as many columns as CARD_MIN allows), the multi-well card
|
||||
-- across them, then the idle drills.
|
||||
-- The moon: heading, the well cards, the multi-well card, the tray of idle drills (selling
|
||||
-- the moon is in the aside: aside.lua), each a kept part, top to bottom from (px, py) of
|
||||
-- c. min_h tall at least. Returns its bottom.
|
||||
local function moon_view(g, c, px, py, inner, min_h, H)
|
||||
local st = g.state
|
||||
local moon = st.moon
|
||||
local now = H.now(g)
|
||||
local y = py
|
||||
|
||||
local n_ready = ready.drills(g, moon.id)
|
||||
local claim = "claim?claim-moon=" .. moon.id
|
||||
y = y + part(g, "mo:head", blocks.sig(inner, moon.id, moon.name, moon.kind or false, moon.grade or false,
|
||||
moon.rating or false, moon.description or false, moon.ready, moon.nextEnd or false,
|
||||
(moon.nextEnd or math.huge) <= now, n_ready, marks(g, { claim })), c, px, y, function(root)
|
||||
return heading(g, root, moon, n_ready, inner, H)
|
||||
end)
|
||||
y = y + 14
|
||||
|
||||
-- The cards: a well each (as many columns as CARD_MIN allows; a row as tall as its
|
||||
-- tallest card), the multi-well card across them, then the idle drills.
|
||||
local max = M.supply_max(st, moon)
|
||||
local groups = M.idle_groups(st)
|
||||
local pick_ok = false
|
||||
@@ -739,30 +899,39 @@ local function moon_view(g, c, px, py, inner, min_h, H)
|
||||
if not pick_ok then
|
||||
g.deploy_pick = nil -- (used up)
|
||||
end
|
||||
local enters = {}
|
||||
local cards = {}
|
||||
for _, well in ipairs(moon.wells) do
|
||||
cards[#cards + 1] = well_card(g, c, st, moon, well, max, groups, H, enters)
|
||||
local cols, cw = flex.columns(inner, CARD_MIN, CARD_GAP)
|
||||
local wells = moon.wells or {}
|
||||
local models = {}
|
||||
for i, well in ipairs(wells) do
|
||||
models[i] = card_model(g, st, well, max, groups, H)
|
||||
end
|
||||
for first = 1, #wells, cols do
|
||||
local last = math.min(#wells, first + cols - 1)
|
||||
local rh = 0
|
||||
for i = first, last do
|
||||
rh = math.max(rh, card_h(models[i].slots))
|
||||
end
|
||||
for i = first, last do
|
||||
local m = models[i]
|
||||
part(g, "mo:well:" .. wells[i].id, blocks.sig(cw, rh, m), c, px + (i - first) * (cw + CARD_GAP), y, function(root)
|
||||
well_card(g, root, st, moon, m, cw, rh, H)
|
||||
end)
|
||||
end
|
||||
y = y + rh + (last < #wells and CARD_GAP or 0)
|
||||
end
|
||||
local kids = {
|
||||
col({ min_height = 80, align = "start" }, head),
|
||||
space(0, 14),
|
||||
flex.grid(inner, CARD_MIN, CARD_GAP, cards),
|
||||
}
|
||||
if moon.wide and #(moon.wide.drills or {}) > 0 then
|
||||
kids[#kids + 1] = space(0, CARD_GAP)
|
||||
kids[#kids + 1] = wide_card(g, c, st, moon, inner, H, enters)
|
||||
y = y + CARD_GAP
|
||||
local m = wide_model(g, st, moon, H)
|
||||
y = y + part(g, "mo:wide", blocks.sig(inner, moon.id, m), c, px, y, function(root)
|
||||
return wide_card(g, root, st, moon, m, inner, H)
|
||||
end)
|
||||
end
|
||||
kids[#kids + 1] = space(0, 20)
|
||||
kids[#kids + 1] = tray(g, c, st, groups, inner, H)
|
||||
-- (selling the moon, a destructive action, is in the aside: aside.lua)
|
||||
|
||||
local page = col({ width = inner, min_height = min_h }, kids)
|
||||
fg.build(flex.solve(page, px, py), c)
|
||||
for _, f in ipairs(enters) do
|
||||
f()
|
||||
end
|
||||
return page.y + page.h
|
||||
y = y + 20
|
||||
local tm = tray_model(g, st, groups)
|
||||
y = y + part(g, "mo:tray", blocks.sig(inner, tm), c, px, y, function(root)
|
||||
return tray(g, root, tm, inner, H)
|
||||
end)
|
||||
return math.max(y, py + min_h)
|
||||
end
|
||||
|
||||
|
||||
|
||||
@@ -572,7 +572,7 @@ local function recipe_card(self, H, c, st, rf, r)
|
||||
refine = H.action(self, b, nodes, "refine", function()
|
||||
local q = self.refine_qty and self.refine_qty[r.item] or "1"
|
||||
return { recipe = r.item, qty = (q ~= "" and q or "1") }
|
||||
end)
|
||||
end, nil, nil, "act:refine?recipe=" .. r.item)
|
||||
tooltip.add(b, "Refine the number in the box")
|
||||
-- Enter in the quantity refines (the web's form).
|
||||
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
|
||||
|
||||
@@ -147,6 +147,25 @@ function M.build_teaser(g, root, px, py, inner)
|
||||
ui.rect(p, x + 16, y, w - 32, 1, "edge")
|
||||
return nil
|
||||
end } or nil)
|
||||
-- Moon packs (once the store is open): several at once, cheaper and better, a button
|
||||
-- each under the grade's facts.
|
||||
if #(e.packs or {}) > 0 then
|
||||
local packs = {}
|
||||
for _, pk in ipairs(e.packs) do
|
||||
local label = pk.count .. " moons · " .. theme.currency .. ui.money(pk.price)
|
||||
packs[#packs + 1] = fg.button(root, label, { small = true, disabled = not pk.canAfford }, function(b, nodes)
|
||||
local save = math.floor(pk.discount * 100 + 0.5)
|
||||
tooltip.add(b, pk.canAfford and (pk.count .. " " .. e.grade .. " moons for " .. theme.currency .. ui.money(pk.price)
|
||||
.. " (" .. save .. "% off), each a little richer than a single one")
|
||||
or ("Need " .. theme.currency .. ui.money(pk.price - st.credits) .. " more"))
|
||||
if pk.canAfford then
|
||||
g.h.action(g, b, nodes, "buy-moon", { grade = e.grade, count = pk.count, include = "new-moon" },
|
||||
function() g.nm_grade = nil end, nil, "newmoon:pack:" .. e.grade .. ":" .. pk.count)
|
||||
end
|
||||
end)
|
||||
end
|
||||
rows[#rows + 1] = row({ height = 40, align = "start", padding = { 0, 16, 10, 56 }, gap = 8 }, packs)
|
||||
end
|
||||
end
|
||||
local picker = panel_box({ width = inner, padding = { 14, 0, 6, 0 } }, {
|
||||
row({ padding = { 0, 16, 0, 16 } }, { fg.label(root, "Moon grades", "cap", 12) }),
|
||||
@@ -516,6 +535,9 @@ function M:bind()
|
||||
if r.card then
|
||||
if g.autoplay then
|
||||
g.autoplay:register("reveal-close", {}, function() self:close() end)
|
||||
if r.another_btn and r.another_ok then
|
||||
g.autoplay:register("reveal-buy", {}, function() self:buy_another() end)
|
||||
end
|
||||
end
|
||||
if r.another_btn and not r.another_ok then
|
||||
tooltip.add(r.another_btn, r.another_why)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
-- gear, a text button per setting or action with its icon on the right: How to play (the
|
||||
-- Help view), the UI scale (window pixels per layout unit, times the display's own scale;
|
||||
-- the browser's zoom), fullscreen, sound, theme, reduced motion, Save code and Restore from
|
||||
-- code (savecode.lua), and Grant for admins.
|
||||
-- code (savecode.lua), Grant for admins, and Moon lab (the admin area, admin.lua) for
|
||||
-- admins and in debug builds.
|
||||
-- Saved settings live in the settings file (modules/screen, sfx, theme, anim). Keys: Ctrl
|
||||
-- + / Ctrl - / Ctrl 0, F11 (core/keys). A click outside or Esc closes it.
|
||||
|
||||
@@ -73,6 +74,10 @@ local ICONS = {
|
||||
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
|
||||
ui.text(p, "›", { font = "sym", px = 18, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
||||
end,
|
||||
lab = function(p, x, y)
|
||||
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "cyan")
|
||||
gui.play_flipbook(icon, "moon")
|
||||
end,
|
||||
grant = function(p, x, y)
|
||||
ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
||||
end,
|
||||
@@ -98,7 +103,9 @@ end
|
||||
function M.open(g, right, top)
|
||||
local st = g.state
|
||||
local help = st and st.unlocked and st.unlocked.help
|
||||
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0)
|
||||
local admin = require("screens.game.admin")
|
||||
local lab = st and admin.allowed(g)
|
||||
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0) + (lab and 1 or 0)
|
||||
local h = PAD * 2 + rows * ROW_H + 8
|
||||
g.dialog:open({
|
||||
w = W,
|
||||
@@ -171,6 +178,12 @@ function M.open(g, right, top)
|
||||
item(d, box, y, "Grant (G)", "grant", "grant", close_then(function()
|
||||
require("screens.game.grant").open(g)
|
||||
end))
|
||||
y = y + ROW_H
|
||||
end
|
||||
if lab then
|
||||
item(d, box, y, "Moon lab", "lab", "menu:moon-lab", close_then(function()
|
||||
admin.open(g, "moonlab")
|
||||
end), { disabled = g.reveal:active() })
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
- characters the fonts lack: non-ASCII characters in Lua string literals and in a
|
||||
sample of server texts (GET /api/state of a fixture player, every section) must be in
|
||||
the fonts that draw them (text fonts; the symbols ui.rich sends to the sym font)
|
||||
- the moon generator (scripts/moon-check.mjs: the mmterrain extension's C, built for the
|
||||
host, and terrain.lua's settings side, against the server's terrain.js)
|
||||
- engine warnings in a scenario run's log: font batches / characters / text entries
|
||||
over their limits, out of nodes or animations, stencil overflow, invalid markup,
|
||||
script errors
|
||||
@@ -231,15 +229,6 @@ def check_scenario(api):
|
||||
check_log(os.path.join(ROOT, "dist/drive/check/game.log"))
|
||||
|
||||
|
||||
def check_moon():
|
||||
r = subprocess.run(["node", os.path.join(HERE, "moon-check.mjs")], cwd=ROOT, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True)
|
||||
if r.returncode != 0:
|
||||
fail("moon-check.mjs failed:\n" + "\n".join(l for l in r.stdout.splitlines() if not l.startswith("ok "))[-2000:])
|
||||
else:
|
||||
print("moon: " + (r.stdout.strip().splitlines() or [""])[-1])
|
||||
|
||||
|
||||
def check_flex():
|
||||
r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True)
|
||||
@@ -257,7 +246,6 @@ def main():
|
||||
check_lua()
|
||||
check_flags()
|
||||
check_flex()
|
||||
check_moon()
|
||||
check_fonts(api)
|
||||
if "--static" not in sys.argv:
|
||||
check_scenario(api)
|
||||
|
||||
@@ -63,11 +63,14 @@ def read(name, default=None):
|
||||
|
||||
|
||||
def alive(pid):
|
||||
# Our game or Xvfb still: the pid file outlives a game that exits on its own, and its
|
||||
# number can be reused by another process (stop would kill that one's group).
|
||||
try:
|
||||
os.kill(int(pid), 0)
|
||||
return True
|
||||
with open("/proc/%d/cmdline" % int(pid), "rb") as f:
|
||||
cmd = f.read()
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
return any(n in cmd for n in (b"dmengine", b"MoonMiners", b"Xvfb"))
|
||||
|
||||
|
||||
def snapshot():
|
||||
|
||||
+13
-8
@@ -11,18 +11,19 @@ Usage:
|
||||
scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
|
||||
with run(game, args).
|
||||
Options:
|
||||
--api URL server (default http://localhost:3000; MM_API env)
|
||||
--api URL server (or MM_API; required, no default: a test server, never :3000)
|
||||
--size WxH window and display size (default 1280x800)
|
||||
--out DIR screenshots and game.log (default dist/drive/<scenario>)
|
||||
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default
|
||||
--keep don't stop the game at the end (prints its pid and port)
|
||||
--keep don't stop the game, or its Xvfb, at the end (prints their pids, the
|
||||
port and the display)
|
||||
--attach PORT don't start a game: drive the one whose engine service is on PORT
|
||||
(`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
|
||||
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
|
||||
--log FILE the attached game's log (default dist/dev/game.log)
|
||||
--config K=V extra engine config (mm.player=..., repeatable)
|
||||
--fixture make a test player first (scripts/fixture.py against --api, which must
|
||||
then be given, or MM_API) and play as it; the scenario gets its ids as game.fixture
|
||||
--fixture make a test player first (scripts/fixture.py against --api) and play as
|
||||
it; the scenario gets its ids as game.fixture
|
||||
--fixture-file F use the ids saved in F (a fixture.py output) instead
|
||||
|
||||
The game it starts saves its files (player ids, settings) under <out>/data (or
|
||||
@@ -381,7 +382,7 @@ def main():
|
||||
ap = argparse.ArgumentParser(add_help=True)
|
||||
ap.add_argument("scenario")
|
||||
ap.add_argument("rest", nargs="*")
|
||||
ap.add_argument("--api", default=os.environ.get("MM_API", "http://localhost:3000"))
|
||||
ap.add_argument("--api", default=os.environ.get("MM_API"))
|
||||
ap.add_argument("--size", default="1280x800")
|
||||
ap.add_argument("--out")
|
||||
ap.add_argument("--bundle", action="store_true")
|
||||
@@ -392,6 +393,9 @@ def main():
|
||||
ap.add_argument("--fixture", action="store_true")
|
||||
ap.add_argument("--fixture-file")
|
||||
args = ap.parse_args()
|
||||
if not args.api:
|
||||
# (No default: the game makes a player and plays on whatever server this is.)
|
||||
sys.exit("drive.py: pass --api or set MM_API to a test server (no default)")
|
||||
|
||||
if args.attach:
|
||||
disp = os.path.join(ROOT, "dist/dev/display")
|
||||
@@ -413,6 +417,10 @@ def main():
|
||||
try:
|
||||
code = subprocess.call([sys.executable] + sys.argv, env=env)
|
||||
finally:
|
||||
if args.keep:
|
||||
# The kept game draws on this display: leave it running too.
|
||||
print("Xvfb pid %d, display :%s" % (xvfb.pid, num))
|
||||
else:
|
||||
xvfb.terminate()
|
||||
xvfb.wait()
|
||||
sys.exit(code)
|
||||
@@ -428,9 +436,6 @@ def main():
|
||||
if not args.attach:
|
||||
args.config.append("mm.player=" + fixture["player"])
|
||||
elif args.fixture:
|
||||
if "--api" not in sys.argv and not os.environ.get("MM_API"):
|
||||
sys.exit("drive.py: --fixture creates a player on the server: pass --api or set MM_API "
|
||||
"to a test server (no default)")
|
||||
fixture = json.loads(subprocess.check_output([sys.executable, os.path.join(HERE, "fixture.py"), args.api]))
|
||||
args.config.append("mm.player=" + fixture["player"])
|
||||
print("fixture player", fixture["player"])
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
// Checks the moon generator against the server's terrain.js: the native one
|
||||
// (mmterrain/src/terrain.c, built here for the host and loaded into luajit through the
|
||||
// FFI) gives the same mesh within float32 rounding, and the Lua settings side
|
||||
// (modules/moon/terrain.lua: random draws, traits, resolve, the strip) matches too.
|
||||
// Every seed runs with the default settings and a few others (each technique, many
|
||||
// craters, no ridges or patches). Needs node, luajit and a C compiler (cc).
|
||||
//
|
||||
// node scripts/moon-check.mjs [resolution] [seeds...]
|
||||
//
|
||||
// terrain.js is read from $MM_SERVER (default ../server).
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const server = process.env.MM_SERVER || path.join(root, "..", "server");
|
||||
const T = await import(pathToFileURL(path.join(server, "web/static/js/terrain.js")));
|
||||
const [res = "32", ...rest] = process.argv.slice(2);
|
||||
// The fixed seeds, plus moon-id-like ones (deterministic).
|
||||
const ids = [];
|
||||
{
|
||||
let h = 0x9e3779b9;
|
||||
const next = () => ((h = (Math.imul(h ^ (h >>> 15), 0x2c1b3c6d) + 0x6d2b79f5) >>> 0), h.toString(16).padStart(8, "0"));
|
||||
for (let i = 0; i < 24; i++) ids.push(`${next()}-${next().slice(0, 4)}-4${next().slice(0, 3)}-a${next().slice(0, 3)}-${next()}${next().slice(0, 4)}`);
|
||||
}
|
||||
const seeds = rest.length ? rest : ["1042", "1187", "1301", "1555", "29dea5d5-709d-4c55-a269-cd90e4379fe9", ...ids];
|
||||
const fixed = (t) => ({ technique: t, variation: { technique: false } });
|
||||
const SETTINGS = [
|
||||
{}, fixed("fbm"), fixed("ridged"), fixed("warped"), fixed("billow"), fixed("craterField"),
|
||||
{ craters: { count: 1500, smoothness: 0, maxRadius: 0.4 } }, { ridge: { strength: 0 }, shading: { patchAmount: 0 } },
|
||||
];
|
||||
// Each case: a seed and a settings index (the default settings for every seed, the
|
||||
// others over a rotating subset).
|
||||
const cases = [];
|
||||
seeds.forEach((seed, i) => {
|
||||
cases.push([seed, 0]);
|
||||
for (let s = 1; s < SETTINGS.length; s++) if ((i + s) % 4 === 0 || rest.length) cases.push([seed, s]);
|
||||
});
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mmterrain-"));
|
||||
const lib = path.join(dir, "libmmterrain.so");
|
||||
execFileSync("cc", ["-O2", "-std=c99", "-shared", "-fPIC", "-o", lib, path.join(root, "mmterrain/src/terrain.c"), "-lm"]);
|
||||
|
||||
const luaValue = (v) => (typeof v === "object" ? "{" + Object.entries(v).map(([k, x]) => `${k}=${luaValue(x)}`).join(",") + "}" : JSON.stringify(v));
|
||||
const lua = `
|
||||
package.path = ${JSON.stringify(root + "/?.lua;")} .. package.path
|
||||
local T = require("modules.moon.terrain")
|
||||
local SETTINGS = { ${SETTINGS.map(luaValue).join(", ")} }
|
||||
local ffi = require("ffi")
|
||||
ffi.cdef[[
|
||||
typedef struct mt_params mt_params;
|
||||
typedef struct mt_gen mt_gen;
|
||||
int mt_params_from_array(const double* a, int n, mt_params* p);
|
||||
mt_gen* mt_new(const mt_params* p);
|
||||
void mt_free(mt_gen* g);
|
||||
int mt_step(mt_gen* g, double budget);
|
||||
int mt_count(const mt_gen* g);
|
||||
int mt_n(const mt_gen* g);
|
||||
const double* mt_positions(const mt_gen* g);
|
||||
const double* mt_normals(const mt_gen* g);
|
||||
const double* mt_colors(const mt_gen* g);
|
||||
int mt_strip_len(int n);
|
||||
void mt_strip(int n, int* out);
|
||||
void mt_fill(const mt_gen* g, float* pos, float* nor, float* col, int stride, int from, int to);
|
||||
]]
|
||||
local N = ffi.load(${JSON.stringify(lib)})
|
||||
local function num(x) return string.format("%.17g", x) end
|
||||
local function carr(p, n) local o = {} for k = 0, n - 1 do o[k + 1] = num(p[k]) end return "[" .. table.concat(o, ",") .. "]" end
|
||||
local out = {}
|
||||
for i = 2, #arg, 2 do -- arg[1]: resolution, then seed, settings index pairs
|
||||
local seed, si = arg[i], tonumber(arg[i + 1])
|
||||
local s = T.merge_settings(SETTINGS[si + 1])
|
||||
local r = T.rng(T.hash_string(seed))
|
||||
local draws = {} for k = 1, 16 do draws[k] = num(r()) end
|
||||
local tr = T.traits(s, seed)
|
||||
local p = T.resolve(s, seed)
|
||||
p.resolution = tonumber(arg[1])
|
||||
do
|
||||
local a = T.native_params(p)
|
||||
local buf = ffi.new("double[?]", #a, a)
|
||||
local params = ffi.new("char[1024]") -- (larger than mt_params)
|
||||
assert(N.mt_params_from_array(buf, #a, ffi.cast("mt_params*", params)) == 1)
|
||||
local g = N.mt_new(ffi.cast("mt_params*", params))
|
||||
local steps = 0
|
||||
repeat steps = steps + 1 until N.mt_step(g, 0.0005) == 1
|
||||
local c = N.mt_count(g) * 3
|
||||
-- The strip: the native order equals the Lua's (terrain.strip), and its fill writes
|
||||
-- those vertices.
|
||||
local strip = T.strip(N.mt_n(g))
|
||||
local len = N.mt_strip_len(N.mt_n(g))
|
||||
local sidx = ffi.new("int[?]", len)
|
||||
N.mt_strip(N.mt_n(g), sidx)
|
||||
local same = len == #strip
|
||||
for k = 1, #strip do if not same then break end same = sidx[k - 1] == strip[k] end
|
||||
local fill = ffi.new("float[?]", len * 9)
|
||||
local half = math.floor(len / 2) -- (in two ranges, like the web's chunks)
|
||||
N.mt_fill(g, fill, fill + 3, fill + 6, 9, 0, half)
|
||||
N.mt_fill(g, fill, fill + 3, fill + 6, 9, half, len)
|
||||
local P = N.mt_positions(g)
|
||||
local fdiff = 0
|
||||
for k = 1, #strip do
|
||||
local v = strip[k]
|
||||
for d = 0, 2 do fdiff = math.max(fdiff, math.abs(fill[(k - 1) * 9 + d] - P[v * 3 + d])) end
|
||||
end
|
||||
out[#out + 1] = string.format('{"draws":[%s],"traits":[%q,%q,%s,%s,%s,%s],"positions":%s,"normals":%s,"colors":%s,"strip":%s,"fill":%s,"steps":%d}',
|
||||
table.concat(draws, ","), tr.technique, tr.mid, num(tr.shapeStrength), num(tr.ridgeStrength), num(tr.craterCount),
|
||||
num(tr.maxRadius), carr(N.mt_positions(g), c), carr(N.mt_normals(g), c), carr(N.mt_colors(g), c),
|
||||
same and "true" or "false", num(fdiff), steps)
|
||||
N.mt_free(g)
|
||||
end
|
||||
end
|
||||
print("[" .. table.concat(out, ",") .. "]")
|
||||
`;
|
||||
const got = JSON.parse(execFileSync("luajit", ["-", res, ...cases.flat().map(String)], { input: lua, maxBuffer: 1 << 30 }).toString());
|
||||
|
||||
const maxDiff = (a, b) => (a.length !== b.length ? Infinity : a.reduce((d, v, k) => Math.max(d, Math.abs(v - b[k])), 0));
|
||||
const close = (m, g) => {
|
||||
const d = { positions: maxDiff(m.positions, g.positions), normals: maxDiff(m.normals, g.normals), colors: maxDiff(m.colors, g.colors) };
|
||||
return [d.positions < 1e-5 && d.normals < 1e-4 && d.colors < 1e-4, d];
|
||||
};
|
||||
const fmt = (d) => `positions ${d.positions.toExponential(1)}, normals ${d.normals.toExponential(1)}, colors ${d.colors.toExponential(1)}`;
|
||||
let failed = false;
|
||||
cases.forEach(([seed, si], i) => {
|
||||
const s = T.mergeSettings(SETTINGS[si]);
|
||||
const r = T.rng(T.hashString(seed));
|
||||
const draws = Array.from({ length: 16 }, () => r());
|
||||
const t = T.moonTraits(s, seed);
|
||||
const traits = [t.technique, t.mid, t.shapeStrength, t.ridgeStrength, t.craterCount, t.maxRadius];
|
||||
const p = T.resolveMoon(s, seed);
|
||||
p.resolution = Number(res);
|
||||
const m = T.generateMoon(p);
|
||||
const exact = JSON.stringify(draws) === JSON.stringify(got[i].draws) && JSON.stringify(traits) === JSON.stringify(got[i].traits);
|
||||
const nat = got[i];
|
||||
const [natOk, d] = close(m, nat);
|
||||
const ok = exact && natOk && nat.strip && nat.fill < 1e-5;
|
||||
const line = `draws and traits ${exact ? "identical" : "DIFFER"} | native: ${fmt(d)}; strip ${nat.strip ? "same" : "DIFFERS"}, fill ${nat.fill.toExponential(1)}, ${nat.steps} steps`;
|
||||
failed ||= !ok;
|
||||
console.log(`${ok ? "ok " : "FAIL"} ${seed} [settings ${si}] ${t.technique}: ${line}`);
|
||||
});
|
||||
console.log(`${cases.length} cases at resolution ${res}: ${failed ? "FAILED" : "all ok"}`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -34,7 +34,7 @@ def run(game, args):
|
||||
clicks = [
|
||||
("moons", "act:claim?claim-well"),
|
||||
("refinery", "act:collect?job"),
|
||||
("market", "act:sell"),
|
||||
("market", "act:sell?pick"),
|
||||
("store", "act:buy-tool?id"),
|
||||
("equipment", "act:upgrade?tool"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Moon lab (admin area, screens/game/moonlab.lua): the gear menu's Moon lab opens it (needs
|
||||
an MM_DEV=1 server, where everyone is an admin, or a debug build), the saved settings load,
|
||||
the layout lints, a real mouse drag on a slider follows the pointer and rebuilds the moon
|
||||
(the build times are printed: "moon: ... built, N vertices, M ms"), Save posts the
|
||||
settings (GET answers them back), and Back to the game leaves the lab with the game's
|
||||
moon on the saved settings."""
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
M = "require('modules.moon.moon')"
|
||||
|
||||
|
||||
def built(game):
|
||||
game.wait_for("not %s.building and not g.lab.pending" % M)
|
||||
return game.eval("%s.last_build and %s.last_build.ms" % (M, M))
|
||||
|
||||
|
||||
def run(game, args):
|
||||
game.idle()
|
||||
game.click("settings")
|
||||
game.wait_for("g.dialog:is_open()")
|
||||
game.sleep(0.3)
|
||||
game.click("menu:moon-lab")
|
||||
game.wait_for("g.admin == 'moonlab' and g.lab and g.lab.loaded")
|
||||
game.idle()
|
||||
ms0 = built(game)
|
||||
game.shot("moonlab", settle=0.5)
|
||||
game.lint("moonlab")
|
||||
|
||||
# A click on a slider's track: the value under the pointer (the middle).
|
||||
game.scroll_to("slider:craters.count")
|
||||
game.click("slider:craters.count")
|
||||
game.expect(game.eval("g.lab.draft.craters.count") == 1000, "a click in the middle sets the middle")
|
||||
ms1 = built(game)
|
||||
|
||||
# A real drag across a slider: the knob follows, the moon rebuilds as it goes.
|
||||
game.scroll_to("slider:shape.strength")
|
||||
x, y = game.os_point("slider:shape.strength")
|
||||
game.os_move(x - 40, y)
|
||||
game.xdo("mousedown", "1")
|
||||
t0 = time.time()
|
||||
for i in range(20):
|
||||
game.os_move(x - 40 + i * 8, y)
|
||||
time.sleep(0.03)
|
||||
game.xdo("mouseup", "1")
|
||||
game.idle()
|
||||
v = game.eval("g.lab.draft.shape.strength")
|
||||
ms2 = built(game)
|
||||
print("moonlab: build ms first %s, click %s, after drag %s (drag ended at strength %s, %.1f s)" % (ms0, ms1, ms2, v, time.time() - t0))
|
||||
game.expect(v and v > 0.1, "the drag moved the slider right of the middle: %r" % v)
|
||||
game.shot("moonlab-dragged", settle=0.3)
|
||||
|
||||
game.click_check("lab:save")
|
||||
game.wait_for("not g.lab.saving")
|
||||
game.expect(game.eval("g.lab.error") is None, "saved")
|
||||
with urllib.request.urlopen(game.api + "/api/moon-settings") as r:
|
||||
saved = json.load(r)
|
||||
game.expect(saved["craters"]["count"] == 1000, "the server has the new settings")
|
||||
|
||||
game.click("admin:back")
|
||||
game.wait_for("g.admin == nil")
|
||||
game.idle()
|
||||
game.expect(game.eval("%s.settings.craters.count" % M) == 1000, "the game's moon uses the saved settings")
|
||||
game.shot("moonlab-back", settle=0.5)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""The settings menu's rows take real mouse clicks (xdotool), also where the menu lies over
|
||||
the moon viewport (its drag used to take the press)."""
|
||||
|
||||
|
||||
def run(game, args):
|
||||
game.idle()
|
||||
checks = [
|
||||
("mute", "tostring(require('modules.sfx').muted())"),
|
||||
("theme", "tostring(require('modules.theme').light)"),
|
||||
("motion", "tostring(require('modules.anim').reduced)"),
|
||||
("dialog:fullscreen", "tostring(mmwindow.is_fullscreen())"),
|
||||
("menu:save-code", "tostring(g.dialog:is_open())"),
|
||||
]
|
||||
for id_, expr in checks:
|
||||
game.os_click("settings")
|
||||
game.wait_for("g.dialog:is_open()", timeout=5)
|
||||
game.sleep(0.8)
|
||||
before = game.eval(expr)
|
||||
game.os_click(id_)
|
||||
game.sleep(1.2)
|
||||
after = game.eval(expr)
|
||||
print(" %-18s %s -> %s dialog open: %s" % (id_, before, after, game.eval("tostring(g.dialog:is_open())")))
|
||||
game.shot("after-" + id_.replace(":", "_"), settle=0.2)
|
||||
game.key("key_esc")
|
||||
game.sleep(0.6)
|
||||
@@ -17,6 +17,10 @@ if [ -z "${MM_IN_XVFB:-}" ]; then
|
||||
fi
|
||||
|
||||
export LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-1}
|
||||
# The game's saved files (player ids, settings) go beside the screenshot, never into the
|
||||
# player's own (~/.local/share/moonminers): mm.new_player=1 would clear their saved id.
|
||||
export XDG_DATA_HOME=${MM_SHOT_DATA:-$(dirname "$out")/data}
|
||||
mkdir -p "$XDG_DATA_HOME"
|
||||
cd "$(dirname "$bin")"
|
||||
log=$(dirname "$out")/shot.log
|
||||
TIMEOUT=${TIMEOUT:-120}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
# any build (no test hook needed), so an old bundle can be compared with a new one:
|
||||
# scripts/views.sh dist/baseline/MoonMiners/MoonMiners.x86_64 dist/compare/before fx.json
|
||||
# scripts/views.sh dist/x86_64-linux/MoonMiners/MoonMiners.x86_64 dist/compare/after fx.json
|
||||
# API base: MM_API (default http://localhost:3000).
|
||||
# API base: MM_API (required: a test server, no default).
|
||||
set -euo pipefail
|
||||
bin=$1; out=$2; fx=$3; size=${4:-1280x800}
|
||||
api=${MM_API:-http://localhost:3000}
|
||||
api=${MM_API:?views.sh: set MM_API to a test server (no default)}
|
||||
get() { python3 -c "import json,sys;print(json.load(open('$fx'))['$1'])"; }
|
||||
player=$(get player); moon=$(get moon); well=$(get well); drill=$(get drill_mining)
|
||||
settle=4; [ "$size" != 1280x800 ] && settle=15
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *db-env
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||
TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For
|
||||
expose:
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ services:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: moonminers
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
|
||||
|
||||
+13
-3
@@ -22,6 +22,8 @@ die() { echo "wt: $*" >&2; exit 1; }
|
||||
task_vars() {
|
||||
TASK=${1:-}
|
||||
[[ $TASK =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "task name: lowercase letters, digits and -"
|
||||
# mm_<task> must fit Postgres's 63-byte names (longer ones are cut short silently).
|
||||
((${#TASK} <= 60)) || die "task name: at most 60 characters"
|
||||
WT=$WS/wt-$TASK
|
||||
DB=mm_${TASK//-/_}
|
||||
ST=$WT/.wt
|
||||
@@ -63,7 +65,8 @@ start_server() {
|
||||
{ tail -5 "$ST/migrate.log"; die "migrate failed (log $ST/migrate.log)"; }
|
||||
rm -f "$ST/pid"
|
||||
# Its own session (not killed with the caller's), pid written by the process itself.
|
||||
(cd "$WT/server" && DB_DATABASE=$DB PORT=$port MM_DEV=1 PIDFILE=$ST/pid \
|
||||
# On 127.0.0.1 only: MM_DEV=1 has no admin login.
|
||||
(cd "$WT/server" && DB_DATABASE=$DB HOST=127.0.0.1 PORT=$port MM_DEV=1 PIDFILE=$ST/pid \
|
||||
setsid -f bash -c 'echo $$ >"$PIDFILE"; exec bin/moonminers serve' \
|
||||
>"$ST/server.log" 2>&1 </dev/null)
|
||||
for _ in $(seq 25); do [[ -s $ST/pid ]] && break; sleep 0.1; done
|
||||
@@ -114,7 +117,14 @@ up() {
|
||||
local s=$WT/server ms=$MAIN/server
|
||||
[[ -e $s/web/static/css/app.css || ! -e $ms/web/static/css/app.css ]] || { mkdir -p "$s/web/static/css"; cp "$ms/web/static/css/app.css" "$s/web/static/css/"; }
|
||||
[[ -e $s/web/static/app || ! -d $ms/web/static/app ]] || cp -a "$ms/web/static/app" "$s/web/static/app"
|
||||
# The copied .env names this worktree's own database, so anything run here (migrate,
|
||||
# tests, the server) reaches mm_<task>, never the main checkout's.
|
||||
[[ -e $WT/.env ]] || cp "$MAIN/.env" "$WT/.env"
|
||||
if grep -q '^DB_DATABASE=' "$WT/.env"; then
|
||||
sed -i "s/^DB_DATABASE=.*/DB_DATABASE=$DB/" "$WT/.env"
|
||||
else
|
||||
echo "DB_DATABASE=$DB" >>"$WT/.env"
|
||||
fi
|
||||
echo "$DB" >"$ST/db"
|
||||
if [[ -z $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then
|
||||
admin_sql "create database $DB"
|
||||
@@ -126,7 +136,7 @@ up() {
|
||||
start_server
|
||||
fi
|
||||
echo "worktree $WT"
|
||||
echo "MM_API=http://localhost:$(cat "$ST/port")"
|
||||
echo "MM_API=http://127.0.0.1:$(cat "$ST/port")"
|
||||
echo "stop: $0 down $TASK (or rm $TASK to also remove the worktree)"
|
||||
}
|
||||
|
||||
@@ -179,7 +189,7 @@ case ${1:-} in
|
||||
up) up "${2:-}" ;;
|
||||
down) down "${2:-}" ;;
|
||||
rm) remove "${2:-}" ;;
|
||||
restart) task_vars "${2:-}"; stop_server; start_server; echo "MM_API=http://localhost:$(cat "$ST/port")" ;;
|
||||
restart) task_vars "${2:-}"; stop_server; start_server; echo "MM_API=http://127.0.0.1:$(cat "$ST/port")" ;;
|
||||
status) status ;;
|
||||
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
|
||||
esac
|
||||
@@ -5,6 +5,4 @@ node_modules
|
||||
.env*
|
||||
.vscode
|
||||
.DS_Store
|
||||
web/static/css/app.css
|
||||
art
|
||||
web/static/app
|
||||
@@ -1,8 +1,6 @@
|
||||
# Build output
|
||||
/bin/
|
||||
/moonminers
|
||||
/web/static/css/app.css
|
||||
/web/static/app/
|
||||
|
||||
# Local env files (never commit)
|
||||
.env
|
||||
|
||||
+84
-99
@@ -1,22 +1,17 @@
|
||||
# AGENTS.md — Moon Miners server
|
||||
|
||||
The Go server and the web client of Moon Miners. What the game is, the repo layout and the
|
||||
shared rules: `../AGENTS.md`. Run every command below from this directory (`server/`).
|
||||
The Go server of Moon Miners: game rules, the JSON API the Defold client plays on, and the
|
||||
admin pages. What the game is, the repo layout and the shared rules: `../AGENTS.md`. Run
|
||||
every command below from this directory (`server/`).
|
||||
|
||||
## Stack
|
||||
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
|
||||
package in `server/`; templates, static files and SQL are embedded with `go:embed`.
|
||||
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
|
||||
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
|
||||
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
|
||||
- Experiment: a second web client at `/app` (`web/app/`), Preact + JSX + `@preact/signals`
|
||||
(vendored in `web/app/vendor`, no other libraries, no preact/compat), bundled by esbuild
|
||||
(standalone binary fetched like Tailwind, `make app`) into `web/static/app/` (built, not
|
||||
committed). See "web/app" below.
|
||||
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
|
||||
The viewport in the aside shows the selected moon, generated procedurally from its id
|
||||
(`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
|
||||
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
|
||||
- The game's only client is the Defold one (`../client`), on the JSON API (`api.go`,
|
||||
`api.md`). `GET /` redirects to `/utils` (a Defold web build will be served there later).
|
||||
- The admin pages (`/utils/*`) are server-rendered HTML: Go templates, htmx (vendored in
|
||||
`web/static/vendor`), plain CSS in `web/static/css/admin.css` (no build step, dark and
|
||||
light themes). No bundler, no TypeScript.
|
||||
- Postgres 16, stock image. No functions or extensions in the database.
|
||||
|
||||
## Layout
|
||||
@@ -30,24 +25,28 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
|
||||
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
|
||||
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they can't be sold. The Past tab loads them 10 at a
|
||||
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
|
||||
`loadView` (state, the past page, the resolved view).
|
||||
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
|
||||
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
|
||||
`loadView` (state, the past page, the resolved view). `loadState` leaves moon
|
||||
descriptions out (`apiPageFor` loads the viewed moon's, `loadMoonDescription`);
|
||||
`loadConfig` (resources, store items) is cached per config snapshot.
|
||||
- `views.go`: the `View` (section, moon, well, tab; the API's view params `s=&moon=&well=`),
|
||||
the `Page` view model, formatting helpers, the admin templates' functions.
|
||||
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
|
||||
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
|
||||
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
|
||||
action and responds the same way.
|
||||
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
|
||||
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
|
||||
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422).
|
||||
limits with forms parsed up front, a per-IP limit on new players, IPv6 by its /64), `runAction` (every game
|
||||
action).
|
||||
- `admin.go`: the admin login (`ADMIN_PASSWORD`, `/utils/login`, `requireAdmin` around every
|
||||
other `/utils` route, in-memory sessions in the `mm_admin` cookie; none with `MM_DEV=1`).
|
||||
`admin_test.go`: the gate.
|
||||
- `api.go`, `api.md`: the JSON API: `GET /api/state` (the view models mapped to JSON DTOs;
|
||||
times in unix ms) and `POST /api/act/<action>` (`runAction`, gates included;
|
||||
`{ok, error, toast, fx, state}`, rule errors 422).
|
||||
Player id from `X-MM-User` or the cookie, else a new player (id echoed back).
|
||||
`api_sections.go`: every section's block (equipment and drill panel, store, refinery,
|
||||
advisors, leaderboard, help, new-moon teaser, reveal and buy modal in act responses, moon
|
||||
sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
|
||||
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or
|
||||
`include=`; reasons are codes with the web's text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
|
||||
`include=`; reasons are codes with the player-facing text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
|
||||
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
|
||||
for every test view.
|
||||
for every test view (the test states are in `templates_test.go`).
|
||||
- `config/game.json`: the game config (starting credits/resources/tools, `moonGrades`,
|
||||
resource prices, store items with their two upgrade tracks, moon types: a name, a placeholder
|
||||
description and resource `affinities`, refinery), embedded. `moonGrades` (D, C, B, A, S,
|
||||
@@ -64,7 +63,7 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
|
||||
`config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item`
|
||||
(needed for the inventory foreign key and the SQL in game.go; the tracks aren't stored). Admins override its
|
||||
values live on `/utils/economy` (table editor in `economy.js`; `economy.go`: a patch per
|
||||
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim). Read config values through
|
||||
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim; `progression.go`: the Analysis tab's charts, saved config against the draft: payback per purchase, the clicker sim timeline (on demand, `POST /utils/economy/sim`), curves; drawn as SVG by `chart.go`). Read config values through
|
||||
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
|
||||
- `savecode.go`: save codes (a player's way back from another device; `GET /api/save-code`,
|
||||
actions `save-code` and `restore` handled in `handleAPIAction`, not `runAction`; table
|
||||
@@ -78,15 +77,16 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
|
||||
recent sales of the item, in base-price credits, decaying with a half-life, table
|
||||
`market_pressure`; the factor eases from 1 towards `floor`, and a sale is priced at its
|
||||
average over the pressure it adds). Personal modifiers never change the quote, cards,
|
||||
ticker or 24h history (recomputed, no table); the sell ticket shows them as lines ("+8%
|
||||
ticker or 24h history (recomputed, no table; each item's 24h series is cached for the
|
||||
current tick and config snapshot, `marketSeriesAt`, shared by every request; live events
|
||||
are applied on top per request); the sell ticket shows them as lines ("+8%
|
||||
for you", "−4% from your recent selling"). View model for the Market section (ticker,
|
||||
watchlist, chart, sell ticket; `pick=` in the URL selects a quote).
|
||||
`web/static/js/market.js`: chart crosshair and ticket estimate.
|
||||
watchlist, chart, sell ticket; `pick=` selects a quote).
|
||||
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
|
||||
market price (with live events, without selling pressure or advisors) × `factor`, capped
|
||||
at `maxRatio` × the replacement moon's price (`moonPriceFor(owned-1)`), which it's
|
||||
compared with. Not the last moon, not within `minHoldSecs` of buying it, not while a
|
||||
drill on it is running; finished drills are claimed in the sale. Template: `moon-sale`.
|
||||
drill on it is running; finished drills are claimed in the sale.
|
||||
Also selling tools for `equipmentResale.factor` (below 1) of their credit price (price +
|
||||
its track levels; refined parts not refunded): only idle tools with nothing to claim,
|
||||
never the last tool.
|
||||
@@ -95,96 +95,82 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
|
||||
view model. A batch of n takes `seconds` × min(n, 1 + `bulkLog` × ln n) (`batchSecs`): logarithmic. The most per
|
||||
batch is `maxBatch`, raised for the whole refinery (every slot) by `batchUpgrades`
|
||||
(action `upgrade-batch`, `users.refinery_batch_level`). Tables
|
||||
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
|
||||
`refined_account`, `refinery_job`, `users.refinery_slots`.
|
||||
- `bonus.go`: player bonuses. `bonusesFor` combines the seated advisors (multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
|
||||
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (cycles to a tenth of a second, at least 1 s) (used by
|
||||
`stakeInTx`, the views and the sim); sells and refinery batches use `Bonus.Sell`/`Refine`.
|
||||
Bonuses are fixed when the action runs (a stake keeps its duration and amount).
|
||||
- `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`,
|
||||
`tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds
|
||||
flat, with a price and, higher up, refined `parts`. Levels are per tool
|
||||
flat, with a price and optional refined `parts` (none in game.json now: credits only). Levels are per tool
|
||||
(`inventory_item.yield_level`, `cycle_level`; copies with the same levels are
|
||||
interchangeable, `pickTool`); action `upgrade` (`track=yield|cycle`), counted from the next
|
||||
stake. Reach is the item's and never changes. Also the view models (`TrackRow` with its
|
||||
ladder, on the Equipment card and in the drill panel: `drill=` in the URL, Equipment
|
||||
ladder, on the Equipment card and in the drill panel: `drill=` in the view, Equipment
|
||||
section, in the aside; the toast text).
|
||||
- `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`.
|
||||
- `advisors.go`: advisors config (`advisors`), hire, buy seat, seat/unseat/swap/dismiss
|
||||
rules (only seated advisors give a bonus; dismissing refunds nothing), Advisors section
|
||||
and the advisor panel (`adv=` in the view, in the aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
|
||||
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
|
||||
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
|
||||
`all` for players from before it). New players see only the first moon purchase; deploying
|
||||
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
|
||||
moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
|
||||
tool Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
|
||||
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.
|
||||
tool Advisors. The API sends `unlocked`; `View.resolve` sends locked sections to the moon
|
||||
view and `runAction` refuses locked actions (`actionGates`). A revealed tab shines until
|
||||
opened (`markSeen` in `handleAPIState`). New sections and actions need a gate there.
|
||||
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
|
||||
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged as `admin_*` in
|
||||
`event_log`). In the game, admins (`Page.Admin`, `state.admin`) also get a quick grant
|
||||
dialog (header Grant button or G; action `admin-grant`, 403 for others). `notBanned` gives banned players (`users.banned_at`) a suspended page.
|
||||
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put
|
||||
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`.
|
||||
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon
|
||||
list, sections, side panel).
|
||||
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's
|
||||
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`)
|
||||
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene.
|
||||
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view
|
||||
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game
|
||||
and admin pages.
|
||||
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound
|
||||
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored
|
||||
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for
|
||||
outside control (e.g. ads).
|
||||
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and
|
||||
`web/templates/moons.html`: the moon lab. `moons.go`: moon settings endpoints, `isAdmin`.
|
||||
`moondesc.go`: moon descriptions generated from the look (mirrors terrain.js `moonTraits`;
|
||||
`scripts/check-moon-traits.mjs` in `make check` keeps them in sync) and the wells.
|
||||
- `reveal.go`, `web/static/js/reveal.js`: the new-moon section (`s=new-moon`, a teaser with a
|
||||
shrouded moon in the viewport, `mystery` in the scene data) and the reveal after
|
||||
`buy-moon` (the response carries `#reveal` with the moon's card; scene.js plays the
|
||||
burst, fx.js waits for `mm:revealed` before counting the balance down).
|
||||
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged
|
||||
as `admin_*` in `event_log` with `"admin": "admin"`). In the game, API players in
|
||||
`ADMIN_USER_IDS` (`state.admin`) get a quick grant (action `admin-grant`, 403 for others).
|
||||
Banned players (`users.banned_at`) get a 403 from the API.
|
||||
`web/templates/admin.html`: the page head (`head`, loads `admin.css`), the shared admin nav
|
||||
(`{{template "admin-nav" "<page>"}}`, put it on every `/utils` page; it has the logout),
|
||||
the confirm dialog, the login page. `/utils` redirects to `/utils/players`.
|
||||
- `web/static/js/`: the admin pages' JS. `tabs.js`: sliding tab highlight (`data-tabs`).
|
||||
`enter.js`: entrance animations. `ui.js`: the custom confirm dialog (`hx-confirm`) and
|
||||
toasts. `liveops.js`: wires the dialog on the htmx pages. `economy.js`: the economy editor.
|
||||
- `web/static/assets/`: tool and advisor images (the API's catalog image URLs).
|
||||
- `moons.go`: moon settings endpoints (`/api/moon-settings`; saving: admin session, an
|
||||
`ADMIN_USER_IDS` player or `MM_DEV`), `isAdmin`. `moondesc.go`: moon descriptions
|
||||
generated from the look (mirrors the Defold client's `modules/moon/terrain.lua`, `traits`;
|
||||
`go run . moon-traits` prints Go's side for comparing; nothing checks them automatically
|
||||
any more) and the wells.
|
||||
- `reveal.go`: the new-moon section (`s=new-moon`, a teaser with a shrouded moon, `mystery`
|
||||
in the scene data) and the reveal after `buy-moon` (the act response's `reveal`).
|
||||
`rating.go`: a new moon's rating, the percentile of its base-price value among a cached
|
||||
sample of `rollWells` (resampled when the config changes), stored in
|
||||
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
|
||||
and the star chip in the moon list and header.
|
||||
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
|
||||
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
|
||||
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
|
||||
via `eventMultiplier` in game.go, `announcement`; both in the API's `banner`).
|
||||
- `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in
|
||||
use; the tools show their original images.
|
||||
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
|
||||
- `web/app/` (paused experiment: the Preact client, `GET /app`, template `spa.html`): the
|
||||
Moons slice on the JSON API; other sections link to the classic client. `session.js` is
|
||||
like the Defold client's session (`base`, `pending`, outbox with `clientSeq`, rollback on
|
||||
422), `predict.js` a port of the Defold `predict.lua`. Details in each file's header.
|
||||
Rules: pass signals to JSX as they are; read `.value` only in logic, `computed()` and
|
||||
effects (`scripts/check-signals-jsx.mjs` in `make check`). Key list items by stable ids
|
||||
(drills by tool id, not stake id); never swap an element that animates for another.
|
||||
Animate with transform/opacity (CSS, Web Animations), FLIP (`flip.js`) for rows that move.
|
||||
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–020):
|
||||
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–022):
|
||||
changes for existing ones, applied on every `migrate`, so each must be idempotent.
|
||||
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
|
||||
- `art/`: sources of the store and advisor images (Blender scripts, prompts);
|
||||
they write into `web/static/assets`.
|
||||
|
||||
## Commands
|
||||
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind and
|
||||
esbuild (the Preact client) watching. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every
|
||||
game request that long, to try both clients on a slow connection.
|
||||
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`,
|
||||
moon traits check, the Preact client's `.value` check and build), `make migrate`,
|
||||
`make load-config`. `go run . sim` prints the economy analysis.
|
||||
- `make dev`: server on :3000 (`MM_DEV=1`: no admin login) that reloads templates/static
|
||||
from disk. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every game API request that
|
||||
long, to try the client on a slow connection.
|
||||
- `make build`, `make check` (gofmt, vet, template, API and admin gate tests, JS syntax of
|
||||
every `web/static/js/*.js`), `make migrate`, `make load-config`. `go run . sim` prints the
|
||||
economy analysis.
|
||||
- Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the
|
||||
templates like the server and renders every page and region against in-memory game states;
|
||||
when you add a page, section or view model branch, make sure its states/views reach it.
|
||||
admin templates like the server and renders every page against in-memory data; when you
|
||||
add a page or a branch, make sure its data reaches it.
|
||||
- Local DB: `podman-compose up -d db` (from the repo root) then `make migrate` (see README).
|
||||
Agents: `../scripts/wt up <task>` gives a worktree its own database and server instead.
|
||||
There is no other test suite, except `wells_test.go` (`go test -run Wells -v`: well roll
|
||||
distribution, value by well count, resale odds, sim seeds).
|
||||
Other tests: `go test ./...` (game rules on in-memory states, no database);
|
||||
`wells_test.go` (`go test -run Wells -v`: well roll distribution, value by well count,
|
||||
resale odds, sim seeds). `claim_db_test.go` needs a database of its own
|
||||
(`MM_TEST_DATABASE=mm_<task>`, from `scripts/wt`; skipped without it): `TestClaimGolden`
|
||||
compares claim-all's results with `testdata/claim_golden.txt` (`MM_CLAIM_GOLDEN_WRITE=1`
|
||||
rewrites it, only for a deliberate rule change), `TestClaimAllPerf -v` times it.
|
||||
|
||||
## Deployment
|
||||
- Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`;
|
||||
@@ -200,23 +186,22 @@ distribution, value by well count, resale odds, sim seeds).
|
||||
Postgres credentials come from `.env` (in `server/`, else the repo root's `../.env`; only
|
||||
the first one found is read): `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
|
||||
`DB_DATABASE`, plus `ADMIN_DB_USER`/`ADMIN_DB_PASSWORD` for `migrate`. Never read, print or
|
||||
commit `.env`. `ADMIN_USER_IDS` (comma-separated player ids) grants access to `/utils`
|
||||
(unset: nobody); with `MM_DEV=1` everyone is an admin.
|
||||
commit `.env`. `ADMIN_PASSWORD`: the admin pages' password (unset: `/utils` is locked).
|
||||
`ADMIN_USER_IDS` (comma-separated player ids): admins in the game API (`state.admin`,
|
||||
`admin-grant`, saving moon settings). With `MM_DEV=1` there is no admin login and every
|
||||
player is an admin.
|
||||
`PORT` (default 3000) and `HOST` (unset: every interface; `scripts/wt` sets `127.0.0.1`).
|
||||
|
||||
## Conventions
|
||||
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
|
||||
- Layout: header, moon list, main area (one section at a time), and an aside with the 3D
|
||||
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
|
||||
the address bar in sync.
|
||||
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section`
|
||||
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
|
||||
`/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
|
||||
so action parameters must use other names. Give it a sound in fx.js if it needs one.
|
||||
- Clicks give instant feedback only (busy state + spinner). No client-side game logic or
|
||||
prediction; the server is the authority. Pages don't reload on timers: countdown labels
|
||||
(`data-ends`) tick client-side and may enable a Claim button, as display only.
|
||||
- New section: add it to `sections` and `State.page` (views.go) and map it in the API
|
||||
(`addSection`). New action: a case in `server.runAction` (`/api/act/<action>`) and in
|
||||
`api.md`. The client sends the current view (s, moon, well, drill) with every action, so
|
||||
action parameters must use other names.
|
||||
- The server is the authority; clients predict only what `api.md` "Prediction" allows.
|
||||
- New admin page: a route in `adminRoutes` (server.go; it's behind the login), a template
|
||||
that starts with `{{template "head"}}` and has the admin nav, styles in `admin.css`.
|
||||
- SQL is lowercase with snake_case identifiers.
|
||||
- When you add an endpoint, add it to `test-endpoints.restclient`.
|
||||
- New player action: log it with `logEvent(ctx, tx, userID, "<kind>", data)` inside its
|
||||
transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
|
||||
- Dispose any three.js geometry and materials you create.
|
||||
@@ -39,7 +39,8 @@ game is moon and drill prices with wells scaled alike.
|
||||
|
||||
Attachments and the T1–T5 tier ladder are gone. Each drill has two tracks of its own
|
||||
(config `storeItems[].tracks`), 4–5 levels each, every level a flat value: yield (claim per
|
||||
cycle) and cycle (seconds, to a tenth). Reach never changes. Levels 3+ need refined parts.
|
||||
cycle) and cycle (seconds, to a tenth). Reach never changes. Levels cost credits only (the refined parts they once took gated the
|
||||
early game behind a refinery the player doesn't have yet; the code still takes `parts`).
|
||||
Prices: level k of yield costs 0.97 × drill price × (Δclaim / base claim) × (base cycle /
|
||||
cycle at level k−1), cycle level k costs 0.97 × price × (claim at level k−1 / base) × Δ(base
|
||||
cycle / cycle), × 0.95^(k−1), minus the parts' value, two significant digits, never below
|
||||
|
||||
+1
-10
@@ -1,19 +1,10 @@
|
||||
# Production image: one Go binary with the templates, static files and SQL embedded.
|
||||
FROM docker.io/library/golang:1.25-bookworm AS build
|
||||
WORKDIR /src
|
||||
ARG TAILWIND_VERSION=v4.1.14
|
||||
RUN curl -fsSL -o /usr/local/bin/tailwindcss \
|
||||
https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 \
|
||||
&& chmod +x /usr/local/bin/tailwindcss
|
||||
ARG ESBUILD_VERSION=0.28.2
|
||||
RUN curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-${ESBUILD_VERSION}.tgz \
|
||||
| tar xz -C /usr/local/bin --strip-components=2 package/bin/esbuild
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \
|
||||
&& make app ESBUILD=/usr/local/bin/esbuild \
|
||||
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /moonminers /moonminers
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
# Hand-off: Preact web client experiment (branch `s-preact`)
|
||||
|
||||
## Goal
|
||||
Find out whether a web client can feel as smooth and instant as the Defold client (which
|
||||
predicts click results locally and eases corrections). It's built with Preact + JSX +
|
||||
@preact/signals and compiled by esbuild, on the existing JSON API. It's served at `/app`
|
||||
next to the classic htmx client at `/`. The user will compare them by feel: fast
|
||||
claim/redrill/deploy clicks, and animations that never hitch.
|
||||
|
||||
## What's built
|
||||
- `web/app/` (about 2,400 lines without the vendored code):
|
||||
- `session.js`: a port of the Defold `session.lua`. Holds the `base`/`pending` signals,
|
||||
the `shown`/`live` computeds, and an outbox that sends one request at a time with
|
||||
`clientSeq`. A 422 rolls the prediction back. Navigation is predicted. It refetches at
|
||||
`nextEnd` and keeps the URL in sync.
|
||||
- `predict.js`: a port of `predict.lua` (claim, stake, redrill, derive). One deliberate
|
||||
difference: readiness is local (`s.ready || s.end <= now`).
|
||||
- `clock.js`: server clock offset. An `edge` signal is bumped by a timer at each drill
|
||||
end. Also a single rAF ticker loop and eased numbers (`eased`, `hold`/`release`).
|
||||
- `ui.jsx`:
|
||||
- `Num`: eased number, written by the ticker.
|
||||
- `Countdown`.
|
||||
- `DrillBar`: a WAAPI `scaleX` animation keyed by tool id. A server correction changes
|
||||
`playbackRate` so the bar lands on the true end without jumping.
|
||||
- `SupplyBar`: clip-path.
|
||||
- `buttons.jsx`: `ReadyButton`. It dispatches `mm:ready` when the clock (not a new
|
||||
state) enables it, and the classic fx.js then plays the pop, sparks and sound.
|
||||
- `header.jsx`, `moons.jsx`, `side.jsx`: the same markup and classes as the classic
|
||||
templates, so they use `app.css`. `preact.css` covers only the differences. The whole
|
||||
Moons slice: the moon list's Active/Past tabs, search and past pages, a past moon's
|
||||
haul, the multi-well row and claim-wide (not predicted: the server splits it), the
|
||||
moon sale, and the well panel's better-well line ("Deploy there" moves the view, so it
|
||||
waits for the server).
|
||||
- `dialog.jsx`: the classic confirm dialog (its markup, opened by the classic `ui.js`),
|
||||
for the API's `confirm` texts (the moon sale).
|
||||
- `fx.js`: effects at the click, from the predicted diff. Uses exports added to
|
||||
`web/static/js/fx.js` (`floatText`, `stream`, `mark`, `centerOf`, `formatMoney`) and to
|
||||
`sfx.js`.
|
||||
- `flip.js`: FLIP for keyed rows, with positions relative to the container.
|
||||
- `scene.js` + `scene-worker.js`: the classic `MoonScene` runs in a worker on an
|
||||
OffscreenCanvas. It uses stand-ins for the canvas, its container, `ResizeObserver` and
|
||||
`document`. Pointer and wheel events and the size are forwarded from the page. Drag and
|
||||
zoom were verified. If the worker fails it falls back to the page's thread;
|
||||
`?mainscene` forces that.
|
||||
- `vendor/`: preact 11.0.1 and signals 2.11.3 / signals-core 1.14.4. `jsconfig.json`
|
||||
holds the path aliases, including `three`, used by the worker bundle.
|
||||
- Go: `GET /app` renders `web/templates/spa.html`. `MM_API_DELAY_MS` (dev only) delays
|
||||
`/act/`, `/api/act/`, `/api/state` and htmx `GET /` for both clients.
|
||||
- Build:
|
||||
- `make app`: esbuild is fetched into `bin/` from the npm tarball. It outputs `app.js`,
|
||||
`app.css`, `scene-worker.js` and `moon-worker.js` to `web/static/app/`, which is
|
||||
gitignored and in `.dockerignore`.
|
||||
- `make dev` watches the client.
|
||||
- `make build` depends on `app`.
|
||||
- The Dockerfile fetches esbuild and runs `make app`.
|
||||
- `make check` (passes) also runs `scripts/check-signals-jsx.mjs`, which flags `.value`
|
||||
inside JSX, and builds the client.
|
||||
- AGENTS.md documents `web/app` and its rules. `/app` is in `templates_test.go` and
|
||||
`test-endpoints.restclient`.
|
||||
|
||||
## How to try it
|
||||
```
|
||||
cd wt-s-preact && make app && go build -o bin/moonminers .
|
||||
DB_DATABASE=mm_s_preact PORT=3151 MM_DEV=1 ./bin/moonminers serve
|
||||
# second, slow server on the same DB: MM_API_DELAY_MS=300 PORT=3152 ...
|
||||
```
|
||||
Open `http://localhost:3151/app` and `http://localhost:3151/`. They share the same player
|
||||
cookie. The database `mm_s_preact` is kept. `.env` was copied from `../server/.env` and is
|
||||
gitignored. **The servers are stopped now**; restart them with the commands above.
|
||||
|
||||
## Measurements
|
||||
|
||||
### Second run (Oct 20, after the Moons slice was finished)
|
||||
Headless Chrome 155 on macOS over the DevTools protocol (no puppeteer), Metal GPU
|
||||
(`--use-angle=metal --enable-gpu --ignore-gpu-blocklist`), 1440×900. Same method as below:
|
||||
the median of 3 alternating runs; latency from pointerdown to the first frame where the
|
||||
side panel's drill line count changes. Each test starts from a known state set through the
|
||||
API and admin endpoints (the wells topped up, one drone finished on the well). No other
|
||||
engine was running; the machine's load average was 6 to 9 from other work.
|
||||
|
||||
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|
||||
|---|---|---|---|---|
|
||||
| claim to visible result | 30 ms | 13 ms | 347 ms | 14 ms |
|
||||
| redrill to visible result | 31 ms | 15 ms | 331 ms | 14 ms |
|
||||
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 6 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
|
||||
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
|
||||
| dropped frames (>25 ms) per run | 0, 0, 0 | 0, 0, 0 | 0, 0, 1 | 0, 0, 0 |
|
||||
|
||||
Main-thread time for one claim (Chrome's Performance.getMetrics counters, from the click to
|
||||
1.5 s after, 0 ms delay, median of 3):
|
||||
|
||||
| | script | layout | style | all tasks |
|
||||
|---|---|---|---|---|
|
||||
| classic | 25 ms | 5 ms | 9 ms | 69 ms |
|
||||
| app | 11 ms | 8 ms | 10 ms | 62 ms |
|
||||
|
||||
- The classic client's first claim of a page load had 109 ms of script (the AudioContext on
|
||||
the first gesture, as below); the others 24–25 ms.
|
||||
- Most of "all tasks" is the viewport and other frames in the window, the same in both.
|
||||
- With an idle GPU the frame drops are gone for both clients: the earlier noise was the
|
||||
other engine.
|
||||
- Side-by-side screenshots (moon, well panel, multi-well moon, past moons) are identical but
|
||||
for the header's Classic/Admin link.
|
||||
|
||||
### First run
|
||||
Headless Chromium 156 (Playwright's cache) with puppeteer-core, GPU via
|
||||
`--enable-gpu --use-angle=gl-egl`. Swiftshader is far too slow to use. Each figure is the
|
||||
median of 3 alternating runs. The latency is the time from pointerdown to the first frame
|
||||
where the side panel's drill lines change.
|
||||
|
||||
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|
||||
|---|---|---|---|---|
|
||||
| claim to visible result | 52 ms | 14 ms | 342 ms | 12 ms |
|
||||
| redrill to visible result | 41 ms | 11 ms | 329 ms | 8 ms |
|
||||
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 5 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
|
||||
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
|
||||
| dropped frames (>25 ms) per run | 9, 7, 2 | 7, 12, 1 | 9, 5, 1 | 4, 4, 2 |
|
||||
|
||||
- The classic client drops clicks while a request runs: `body.is-busy` sets
|
||||
`pointer-events: none`.
|
||||
- Frame drops were noisy and about the same for both clients. Another agent's Defold engine
|
||||
was using about 460% CPU and the GPU.
|
||||
- Long-animation-frame entries show the frames waiting on the GPU or presentation rather
|
||||
than on scripts.
|
||||
- The worker viewport and the main-thread viewport (`app-main`) showed no clear difference
|
||||
under this load.
|
||||
- The first click in each run has a long task of about 120 ms in both clients. It comes
|
||||
from `sfx.js` creating the AudioContext on the first gesture.
|
||||
- The harness lives in the session scratchpad, which is not kept. If it's needed again,
|
||||
recreate it from this description: `measure.mjs` (in-page rAF sampling, longtask/LoAF
|
||||
observers, a `watch` predicate on the drill-line count, `page.mouse.click`), `setup.mjs`
|
||||
(a player via the admin endpoints: unlock all, 5 drones, credits, a moon) and
|
||||
`runall.sh`.
|
||||
|
||||
## Unfinished / next steps
|
||||
Done since the first hand-off: the well panel and Moons section gaps (better well, multi-well
|
||||
rows and claim-wide, moon sale, past moons tab, search and pages), navigation and sale and
|
||||
mined-out effects, the measurements on a quiet GPU with main-thread time per claim, and the
|
||||
side-by-side screenshots. What's left:
|
||||
1. Other sections (Equipment, Store, Market, Refinery, Advisors, Leaderboard, Help), buying
|
||||
a moon (the new-moon teaser and reveal) and the store link still open the classic client.
|
||||
The API has every section's state and the prediction rules for sells and refinery
|
||||
batches (api.md "Prediction").
|
||||
2. Moon sale, claim-wide and "Deploy there" wait for the server (a spinner); predicting them
|
||||
would need the server's split of multi-well claims and the sale's payout rules.
|
||||
3. The measurement harness lived in a session scratchpad again (CDP: an in-page rAF watch on
|
||||
the drill-line count, clicks through Input.dispatchMouseEvent, Performance.getMetrics
|
||||
around a claim). Worth keeping in `scripts/` if the comparison continues.
|
||||
4. Report: rough line counts are above. Hard parts:
|
||||
- predicted stake ids differ from the server's, so drill rows are keyed by tool id;
|
||||
- easing bar corrections (playbackRate);
|
||||
- OffscreenCanvas shims for OrbitControls (`getRootNode`, pointer capture, readonly
|
||||
`Event.type`);
|
||||
- avoiding ticker writes conflicting with Preact's children (the ticker writes into
|
||||
empty spans).
|
||||
|
||||
## Gotchas
|
||||
- `live` re-derives counts on every `edge`. Components read `live.value` in their body,
|
||||
which is allowed. Never read `.value` inside JSX.
|
||||
- Readiness from the clock: `clockCaused()` compares the time of the last edge with the
|
||||
last `shown` change, because Preact re-renders asynchronously.
|
||||
- The worker bundle includes three.js (about 600 KB). The page itself uses the import map
|
||||
only for the main-thread fallback.
|
||||
- esbuild needs `--tsconfig=web/app/jsconfig.json` so that `three` resolves for
|
||||
`web/static/js/scene.js` too.
|
||||
- Never `pkill -f` a pattern contained in your own command line; it kills your own shell.
|
||||
- `scripts/check-signals-jsx.mjs` flags any `.value` in JSX, including plain objects' fields
|
||||
(the sale lines' `value`): destructure them under another name.
|
||||
- Headless Chrome plays the game's sounds through the speakers; start it with `--mute-audio`.
|
||||
+6
-37
@@ -1,30 +1,11 @@
|
||||
TAILWIND_VERSION := v4.1.14
|
||||
TAILWIND := bin/tailwindcss
|
||||
CSS := web/static/css/app.css
|
||||
ESBUILD_VERSION := 0.28.2
|
||||
ESBUILD := bin/esbuild
|
||||
# The Preact client (web/app) bundled into web/static/app: app.js, app.css and the viewport's
|
||||
# worker with three.js (scene-worker.js, which starts moon-worker.js for meshes). The page loads the classic client's modules
|
||||
# (/static/js/...) by URL.
|
||||
APP := $(ESBUILD) app=web/app/main.jsx scene-worker=web/app/scene-worker.js moon-worker=web/static/js/moon-worker.js --bundle --format=esm --target=es2022 '--external:/static/*' --tsconfig=web/app/jsconfig.json --outdir=web/static/app
|
||||
.PHONY: build dev migrate load-config check
|
||||
|
||||
.PHONY: build dev css app migrate load-config check
|
||||
|
||||
build: css app
|
||||
build:
|
||||
go build -o bin/moonminers .
|
||||
|
||||
# Server on :3000 that reloads templates and static files from disk; CSS and the Preact
|
||||
# client rebuild on change.
|
||||
dev: $(TAILWIND) $(ESBUILD)
|
||||
$(TAILWIND) -i web/css/app.css -o $(CSS) --watch=always & \
|
||||
$(APP) --sourcemap --watch=forever & \
|
||||
trap 'kill $$(jobs -p)' EXIT; MM_DEV=1 go run . serve
|
||||
|
||||
css: $(TAILWIND)
|
||||
$(TAILWIND) -i web/css/app.css -o $(CSS) --minify
|
||||
|
||||
app: $(ESBUILD)
|
||||
$(APP) --minify
|
||||
# Server on :3000 that reloads templates and static files from disk.
|
||||
dev:
|
||||
MM_DEV=1 go run . serve
|
||||
|
||||
migrate:
|
||||
go run . migrate
|
||||
@@ -34,17 +15,5 @@ load-config:
|
||||
|
||||
check:
|
||||
test -z "$$(gofmt -l .)" && go vet ./...
|
||||
go test -run '^(TestTemplates|TestAPI|TestSharedQuotes)$$' -count=1 .
|
||||
go test -run '^(TestTemplates|TestAPI|TestSharedQuotes|TestAdminGate)$$' -count=1 .
|
||||
for f in web/static/js/*.js; do node --check $$f || exit 1; done
|
||||
node --no-warnings scripts/check-moon-traits.mjs
|
||||
node scripts/check-signals-jsx.mjs web/app
|
||||
$(APP) --minify --log-level=warning
|
||||
|
||||
$(ESBUILD):
|
||||
mkdir -p bin
|
||||
curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-$(ESBUILD_VERSION).tgz | tar xz -C bin --strip-components=2 package/bin/esbuild
|
||||
|
||||
$(TAILWIND):
|
||||
mkdir -p bin
|
||||
curl -fsSL -o $@ https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
|
||||
chmod +x $@
|
||||
+56
-62
@@ -1,19 +1,17 @@
|
||||
# Moon Miners server
|
||||
|
||||
A browser mining game. Players own moons (staking sources) with resource wells, buy and
|
||||
The server of a mining game. Players own moons (staking sources) with resource wells, buy and
|
||||
upgrade mining tools, stake them on wells, claim resources when the stake completes, and sell
|
||||
resources for credits (₥). Drill cycles last seconds, so play is active. The refinery turns
|
||||
resources into refined items on a timer; those sell for more and are needed as parts for
|
||||
the higher levels of the drills' upgrade tracks.
|
||||
|
||||
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
|
||||
a three.js view of the selected moon, generated procedurally from its id. Game rules run in
|
||||
Go, one Postgres transaction per action. The page doesn't reload on timers: countdowns tick
|
||||
in the browser and enable each Claim button when its drill should be done, and the server
|
||||
decides what is actually ready when it's clicked.
|
||||
It's a Go server: game rules run in Go, one Postgres transaction per action, behind a JSON
|
||||
API (`api.md`) that the Defold client (`../client`) plays on. The admin pages (`/utils`) are
|
||||
server-rendered HTML with htmx. `GET /` redirects to `/utils` for now.
|
||||
|
||||
Players join anonymously: on the first visit the server creates a player and stores its id in
|
||||
a cookie. No wallet or sign-up needed.
|
||||
Players join anonymously: on the first request the server creates a player and sends its id
|
||||
back (`X-MM-User`, also a cookie). No wallet or sign-up needed.
|
||||
|
||||
This is `server/` in the repo (the Defold client is `../client`). Run the commands below
|
||||
from this directory.
|
||||
@@ -24,8 +22,6 @@ from this directory.
|
||||
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
|
||||
- Node.js, only for `make check` (syntax-checks the JS)
|
||||
|
||||
`make` downloads the Tailwind standalone CLI and esbuild into `bin/` on first use.
|
||||
|
||||
## Environment
|
||||
|
||||
The server reads `.env` from its working directory (`server/`), or if there is none, from the
|
||||
@@ -42,11 +38,15 @@ ADMIN_DB_USER=postgres # superuser, only used by `migrate`
|
||||
ADMIN_DB_PASSWORD=postgres
|
||||
```
|
||||
|
||||
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates
|
||||
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated
|
||||
player ids allowed on the admin pages; unset means no admins), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit new
|
||||
players by the last `X-Forwarded-For` address instead of the connection's; new players are
|
||||
limited to 10 a minute per IP).
|
||||
Optional: `PORT` (default 3000), `HOST` (the address to listen on, e.g. `127.0.0.1`; unset:
|
||||
every interface), `DB_SSLMODE` (default `disable`), `ADMIN_PASSWORD` (the
|
||||
password of the admin pages, `/utils`; unset: they're locked), `ADMIN_USER_IDS`
|
||||
(comma-separated player ids that are admins in the game API: `state.admin`, `admin-grant`,
|
||||
saving moon settings), `MM_DEV=1` (reload templates and static files from disk; no admin
|
||||
login, and every player is an admin), `MM_WEB_DIR` (the browser build served at `/`; default `../client/dist/web/MoonMiners`,
|
||||
none there: `/` redirects to `/utils`), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit by
|
||||
the last `X-Forwarded-For` address instead of the connection's, and mark the admin cookie
|
||||
Secure on HTTPS; new players are limited to 10 a minute per IP).
|
||||
|
||||
## Local development
|
||||
|
||||
@@ -59,7 +59,9 @@ limited to 10 a minute per IP).
|
||||
make migrate
|
||||
```
|
||||
|
||||
3. Run the server and open http://localhost:3000:
|
||||
3. Run the server (`MM_DEV=1`: no admin login). The admin is at
|
||||
http://localhost:3000/utils; the game at http://localhost:3000 once the client's
|
||||
`make web` has built it (then restart the server), or run the desktop client against it:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
@@ -69,14 +71,15 @@ limited to 10 a minute per IP).
|
||||
pulling changes (or editing `config/game.json`), run `make migrate` again: new
|
||||
migrations and the store items' numbers only reach the database through it.
|
||||
|
||||
Other commands: `make build` (CSS, the Preact bundle and `bin/moonminers`), `make check` (gofmt, vet, the template
|
||||
check in `templates_test.go`, which renders every page against sample game states without a
|
||||
database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and
|
||||
`terrain.js` agree on moon looks), `make load-config` (sync the store items in the database with `config/game.json`).
|
||||
Other commands: `make build` (`bin/moonminers`), `make check` (gofmt, vet, the template
|
||||
check in `templates_test.go`, which renders every admin page without a database, the API and
|
||||
admin login tests, JS syntax), `make load-config` (sync the store items in the database with
|
||||
`config/game.json`).
|
||||
|
||||
The binary's commands: `serve` (default), `migrate`, `load-config`, `sim [file]` (print the
|
||||
economy analysis and a 4-hour greedy-player sim for the game config, or for a config file),
|
||||
and `moon-traits` (JSON on stdin; used by the moon traits check).
|
||||
and `moon-traits` (JSON on stdin; prints the moon looks `moondesc.go` derives, to compare
|
||||
with the client's `terrain.lua`).
|
||||
Reset the local database (compose commands at the repo root): `podman-compose down -v && podman-compose up -d db`, then `make migrate` here.
|
||||
|
||||
## Database changes
|
||||
@@ -98,7 +101,8 @@ attachments (dropped in 020) and advisors, 011 market pressure, 012 onboarding,
|
||||
rating, 016 advisor seats, 017 save codes, 018 moon grade, 019 refinery batch size
|
||||
(`users.refinery_batch_level`), 020 drill upgrade tracks (`inventory_item.yield_level`,
|
||||
`cycle_level`; old tiers became cycle levels, attachments were refunded and dropped,
|
||||
`upgrade_item` dropped, stake cycles in tenths of a second).
|
||||
`upgrade_item` dropped, stake cycles in tenths of a second), 022 indexes for the per-request
|
||||
state reads (moons, wells, tools and stakes by player, stakes by well and by tool).
|
||||
|
||||
## Game config
|
||||
|
||||
@@ -138,17 +142,20 @@ Admins can change these numbers without a deploy on `/utils/economy` (see Admin
|
||||
|
||||
## Admin panels
|
||||
|
||||
Admins are the player ids listed in `ADMIN_USER_IDS` (comma-separated). With `MM_DEV=1`
|
||||
everyone is an admin, so never set it in production. Other players get a 403 (the admin pages
|
||||
show "missing permission"). Every admin page
|
||||
shares one nav: Players · Moons · Economy · Live ops · Game.
|
||||
Server-rendered pages (Go templates, htmx, `web/static/css/admin.css`). Sign in at
|
||||
`/utils/login` with `ADMIN_PASSWORD`: the session is a random token in the `mm_admin` cookie
|
||||
(HttpOnly, SameSite=Lax, 7 days), kept in memory, so a restart signs admins out. A wrong
|
||||
password waits a second; tries are limited to 10 a minute per IP. Without a session, pages
|
||||
redirect to the login and actions get a 401. With `MM_DEV=1` there is no login, so never set
|
||||
it in production; with no `ADMIN_PASSWORD` (and no `MM_DEV`) the admin is locked. Every admin
|
||||
page shares one nav: Players · Economy · Live ops · Log out.
|
||||
|
||||
| Page | What it's for |
|
||||
|---|---|
|
||||
| `/utils` | Redirects to `/utils/players`. |
|
||||
| `/utils` | Redirects to `/utils/players` (`/` redirects here). |
|
||||
| `/utils/login` | The login form; `POST /utils/logout` signs out. |
|
||||
| `/utils/players` | Find players (id prefix or name), sort by last seen, activity, newest, richest. |
|
||||
| `/utils/players/{id}` | One player: balance, resources, refined items, moons and wells, tools and drills, event history (older pages at `/utils/players/{id}/events?page=n`). Grant/remove credits, grant resources and tools, finish running drills, reset, ban/unban. |
|
||||
| `/utils/moons` | Moon lab: live 3D preview with sliders for terrain, craters, colors, bloom, atmosphere and rotation. "Save for the game" applies the look to every moon. |
|
||||
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with its yield and cycle tracks), resources, refined items, market, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
|
||||
| `/utils/liveops` | Dashboard, timed events and announcements. |
|
||||
|
||||
@@ -160,22 +167,22 @@ Actions (all `POST`, confirmed in the page's own dialog):
|
||||
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
|
||||
| `/utils/liveops/{what}` | `events/save`, `announcements/save` |
|
||||
| `/utils/liveops/{what}/{id}/{action}` | toggle or delete an event, delete an announcement |
|
||||
| `/utils/{action}` | `clear-data`, `load-config` (older endpoints, no page) |
|
||||
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` (admins) saves it |
|
||||
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` saves it: the admin cookie, an `X-MM-User` in `ADMIN_USER_IDS`, or `MM_DEV=1` |
|
||||
|
||||
Request examples are in `test-endpoints.restclient`.
|
||||
|
||||
### Players
|
||||
|
||||
Every admin action is logged in `event_log` with the admin's id (`admin_*` kinds, which the
|
||||
live-ops stats ignore). Banned players see "This account is suspended" and their actions get a
|
||||
403.
|
||||
Every admin action is logged in `event_log` (`admin_*` kinds, which the live-ops stats
|
||||
ignore) with `"admin": "admin"` (the admin-grant API action logs the admin player's id).
|
||||
Banned players get a 403 from the API ("This account is suspended").
|
||||
|
||||
### Moons
|
||||
|
||||
Each moon's look is generated from its id (terrain technique, noise, craters, colors). The lab
|
||||
edits the shared settings that every moon starts from; they're stored as one JSON row in
|
||||
`moon_settings` and merged over the defaults in `web/static/js/terrain.js`.
|
||||
Each moon's look is generated from its id (terrain technique, noise, craters, colors) by the
|
||||
Defold client. The shared settings every moon starts from are stored as one JSON row in
|
||||
`moon_settings` (`/api/moon-settings`) and merged over the defaults in the client's
|
||||
`modules/moon/terrain.lua`.
|
||||
|
||||
### Economy
|
||||
|
||||
@@ -193,31 +200,19 @@ and store items are re-synced.
|
||||
- Timed events: yield, sell price or mining speed multipliers, applied server-side.
|
||||
- Announcements.
|
||||
|
||||
Live events and announcements show in a banner at the top of the game. Times are UTC.
|
||||
Live events and announcements reach the game in the API's `banner`. Times are UTC.
|
||||
|
||||
## Interface
|
||||
|
||||
Server-rendered regions swapped by htmx, which morphs them into the page with the idiomorph
|
||||
extension (0BSD, `web/static/vendor/idiomorph`) so elements keep their state; the JS only
|
||||
adds display: `main.js` (htmx glue,
|
||||
viewport, countdowns), `fx.js` (effects per action result), `sfx.js` (sound, below),
|
||||
`tabs.js` (sliding tab highlight), `enter.js` (entrance animations when a view changes),
|
||||
`market.js` (chart crosshair), `ui.js` (confirm dialog and toasts, shared with the admin
|
||||
pages), `scene.js`/`terrain.js`/`moon-worker.js` (the moon).
|
||||
|
||||
## Sound
|
||||
|
||||
`web/static/js/sfx.js` plays sound effects with the Web Audio API: short clips from Kenney's
|
||||
CC0 packs (trimmed and loudness-matched, listed in `web/static/assets/sfx/CREDITS.txt`) and
|
||||
a few sounds synthesized with ZzFX (MIT, `web/static/vendor/zzfx`). The speaker button in the
|
||||
header mutes them (remembered per browser); they are also muted while the tab is hidden.
|
||||
`window.mmAudio.mute()` / `unmute()` mute from outside (e.g. during an ad) without touching
|
||||
the player's setting. Set `localStorage["mm-sfx-debug"] = "1"` to log every sound played.
|
||||
The admin pages' JS (`web/static/js/`): `tabs.js` (sliding tab highlight), `enter.js`
|
||||
(entrance animations), `ui.js` (confirm dialog), `liveops.js`, `economy.js` (the economy
|
||||
editor).
|
||||
|
||||
## Security
|
||||
|
||||
- Players are identified only by the `mm_user` cookie (HttpOnly, SameSite=Lax). New players
|
||||
are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind a proxy).
|
||||
- Players are identified only by their id (`X-MM-User`, or the `mm_user` cookie: HttpOnly,
|
||||
SameSite=Lax). New players are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind
|
||||
a proxy).
|
||||
- The admin pages need the admin login (`ADMIN_PASSWORD`, see Admin panels); without a
|
||||
password they're locked.
|
||||
- Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
|
||||
so oversized ones get a 413.
|
||||
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
|
||||
@@ -239,16 +234,15 @@ First deploy:
|
||||
```dotenv
|
||||
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
|
||||
DB_PASSWORD=... # strong; app user "moonminers"
|
||||
ADMIN_USER_IDS= # fill in at step 5
|
||||
ADMIN_PASSWORD=... # strong; the admin pages at /utils
|
||||
ADMIN_USER_IDS= # optional: player ids that are admins in the game API
|
||||
```
|
||||
|
||||
Never set `MM_DEV` in production: it makes every player an admin.
|
||||
Never set `MM_DEV` in production: it turns off the admin login and makes every player an admin.
|
||||
3. **Domains**: your domain → service `app`, port `3000`, HTTPS on.
|
||||
4. Coming from the old (Next.js) version and want a clean start? Stop the service and delete
|
||||
the `db-data` volume first. Otherwise `migrate` upgrades the old database in place.
|
||||
5. Deploy, open the site once, and copy your player id: the value of the `mm_user` cookie
|
||||
(browser dev tools → Storage/Application → Cookies). Put it in `ADMIN_USER_IDS`
|
||||
(comma-separate several) and redeploy. The admin pages are at `/utils`.
|
||||
5. Deploy. The admin pages are at `/utils` (sign in with `ADMIN_PASSWORD`).
|
||||
|
||||
Updates: push to the branch Dokploy tracks and deploy; `migrate` applies new migrations.
|
||||
Back up `db-data` with `pg_dump` or Dokploy's volume backups.
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
// Admin login for /utils/*: one shared password (ADMIN_PASSWORD), a login form at
|
||||
// /utils/login and a random session token in the mm_admin cookie (kept in memory, so a
|
||||
// restart signs admins out). With MM_DEV=1 there is no login. Without a password (and
|
||||
// without MM_DEV) the admin is locked.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
adminCookie = "mm_admin"
|
||||
adminSessionTTL = 7 * 24 * time.Hour
|
||||
// adminLabel stands for the admin in event_log ("admin" in admin_* events).
|
||||
adminLabel = "admin"
|
||||
)
|
||||
|
||||
// loginFailDelay slows down password guessing (tests set it to 0).
|
||||
var loginFailDelay = time.Second
|
||||
|
||||
// adminSessions are the signed-in admins' tokens and when they expire.
|
||||
type adminSessions struct {
|
||||
mu sync.Mutex
|
||||
tokens map[string]time.Time
|
||||
}
|
||||
|
||||
func (a *adminSessions) add() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
token := hex.EncodeToString(b)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.tokens == nil {
|
||||
a.tokens = map[string]time.Time{}
|
||||
}
|
||||
now := time.Now()
|
||||
for t, exp := range a.tokens {
|
||||
if now.After(exp) {
|
||||
delete(a.tokens, t)
|
||||
}
|
||||
}
|
||||
a.tokens[token] = now.Add(adminSessionTTL)
|
||||
return token
|
||||
}
|
||||
|
||||
func (a *adminSessions) valid(token string) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
exp, ok := a.tokens[token]
|
||||
return ok && time.Now().Before(exp)
|
||||
}
|
||||
|
||||
func (a *adminSessions) remove(token string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
delete(a.tokens, token)
|
||||
}
|
||||
|
||||
// adminLocked: no password set and not in dev, so nobody can sign in.
|
||||
func (s *server) adminLocked() bool { return !s.dev && s.adminPassword == "" }
|
||||
|
||||
// adminSession reports whether the request carries a signed-in admin's cookie.
|
||||
func (s *server) adminSession(r *http.Request) bool {
|
||||
if s.adminLocked() {
|
||||
return false
|
||||
}
|
||||
c, err := r.Cookie(adminCookie)
|
||||
return err == nil && s.admins.valid(c.Value)
|
||||
}
|
||||
|
||||
// requireAdmin lets signed-in admins (or anyone with MM_DEV=1) through. Others get the
|
||||
// login page: a redirect for page loads, 401 with HX-Redirect for htmx and other requests.
|
||||
func (s *server) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.dev || s.adminSession(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
login := "/utils/login"
|
||||
if r.Method == http.MethodGet && r.Header.Get("HX-Request") != "true" {
|
||||
http.Redirect(w, r, login+"?next="+r.URL.EscapedPath(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.Header().Set("HX-Redirect", login)
|
||||
http.Error(w, "Sign in to the admin", http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
|
||||
// LoginPage is the login form's data.
|
||||
type LoginPage struct {
|
||||
Locked bool
|
||||
Error string
|
||||
Next string
|
||||
}
|
||||
|
||||
// safeNext is where to go after signing in: a /utils page, else the admin hub.
|
||||
func safeNext(next string) string {
|
||||
if next == "/utils" || strings.HasPrefix(next, "/utils/") && !strings.HasPrefix(next, "/utils/login") {
|
||||
return next
|
||||
}
|
||||
return "/utils"
|
||||
}
|
||||
|
||||
func (s *server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.dev || s.adminSession(r) {
|
||||
http.Redirect(w, r, safeNext(r.FormValue("next")), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, "login", &LoginPage{Locked: s.adminLocked(), Next: safeNext(r.FormValue("next"))})
|
||||
}
|
||||
|
||||
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
next := safeNext(r.PostFormValue("next"))
|
||||
if s.adminLocked() {
|
||||
s.renderLogin(w, http.StatusForbidden, &LoginPage{Locked: true, Next: next})
|
||||
return
|
||||
}
|
||||
if s.logins != nil && !s.logins.allow(r) {
|
||||
s.renderLogin(w, http.StatusTooManyRequests, &LoginPage{Error: "Too many tries, wait a minute.", Next: next})
|
||||
return
|
||||
}
|
||||
// Hashes, so the comparison doesn't depend on the lengths either.
|
||||
got := sha256.Sum256([]byte(r.PostFormValue("password")))
|
||||
want := sha256.Sum256([]byte(s.adminPassword))
|
||||
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
||||
time.Sleep(loginFailDelay)
|
||||
s.renderLogin(w, http.StatusUnauthorized, &LoginPage{Error: "Wrong password.", Next: next})
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminCookie,
|
||||
Value: s.admins.add(),
|
||||
Path: "/",
|
||||
MaxAge: int(adminSessionTTL / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: secureRequest(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *server) renderLogin(w http.ResponseWriter, status int, page *LoginPage) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
s.render(w, "login", page)
|
||||
}
|
||||
|
||||
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(adminCookie); err == nil {
|
||||
s.admins.remove(c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: adminCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
|
||||
Secure: secureRequest(r), SameSite: http.SameSiteLaxMode})
|
||||
http.Redirect(w, r, "/utils/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// secureRequest: the request came over HTTPS (directly, or through the proxy with
|
||||
// TRUST_PROXY=1), so the cookie can be Secure.
|
||||
func secureRequest(r *http.Request) bool {
|
||||
return r.TLS != nil || os.Getenv("TRUST_PROXY") == "1" && r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The admin login gate: locked without a password, open in dev, and a redirect to the
|
||||
// login without a session.
|
||||
func TestAdminGate(t *testing.T) {
|
||||
loginFailDelay = 0
|
||||
tmpl, err := (&server{}).parseTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) })
|
||||
get := func(s *server, path string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
for _, c := range cookies {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.requireAdmin(ok).ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
login := func(s *server, password string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("POST", "/utils/login", strings.NewReader(url.Values{"password": {password}, "next": {"/utils/economy"}}.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleLogin(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// Dev: no login.
|
||||
if w := get(&server{dev: true}, "/utils/players"); w.Code != http.StatusTeapot {
|
||||
t.Errorf("dev: %d, want through", w.Code)
|
||||
}
|
||||
|
||||
// No password: locked, even with an empty password.
|
||||
locked := &server{templates: tmpl}
|
||||
if w := get(locked, "/utils/players"); w.Code != http.StatusSeeOther {
|
||||
t.Errorf("locked: %d, want a redirect to the login", w.Code)
|
||||
}
|
||||
if w := login(locked, ""); w.Code != http.StatusForbidden || len(w.Result().Cookies()) > 0 {
|
||||
t.Errorf("locked login: %d %v, want 403 and no cookie", w.Code, w.Result().Cookies())
|
||||
}
|
||||
|
||||
// A password: no session redirects to the login, htmx and actions get 401.
|
||||
s := &server{templates: tmpl, adminPassword: "secret"}
|
||||
w := get(s, "/utils/players?q=x")
|
||||
if w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "/utils/login?next=/utils/players") {
|
||||
t.Errorf("no session: %d %q, want a redirect to the login", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
r := httptest.NewRequest("POST", "/utils/economy/save", nil)
|
||||
w = httptest.NewRecorder()
|
||||
s.requireAdmin(ok).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusUnauthorized || w.Header().Get("HX-Redirect") != "/utils/login" {
|
||||
t.Errorf("no session, POST: %d, want 401", w.Code)
|
||||
}
|
||||
if w := get(s, "/utils/players", &http.Cookie{Name: adminCookie, Value: "made-up"}); w.Code != http.StatusSeeOther {
|
||||
t.Errorf("unknown session: %d, want a redirect", w.Code)
|
||||
}
|
||||
|
||||
// Wrong password: no cookie. Right one: a session that gets through.
|
||||
if w := login(s, "wrong"); w.Code != http.StatusUnauthorized || len(w.Result().Cookies()) > 0 {
|
||||
t.Errorf("wrong password: %d, want 401 and no cookie", w.Code)
|
||||
}
|
||||
w = login(s, "secret")
|
||||
cookies := w.Result().Cookies()
|
||||
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/utils/economy" || len(cookies) != 1 {
|
||||
t.Fatalf("login: %d %q %v", w.Code, w.Header().Get("Location"), cookies)
|
||||
}
|
||||
if c := cookies[0]; c.Name != adminCookie || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode {
|
||||
t.Errorf("cookie %+v, want HttpOnly, SameSite Lax", c)
|
||||
}
|
||||
if w := get(s, "/utils/players", cookies[0]); w.Code != http.StatusTeapot {
|
||||
t.Errorf("with session: %d, want through", w.Code)
|
||||
}
|
||||
|
||||
// Only /utils pages after the login.
|
||||
for next, want := range map[string]string{"/utils/players": "/utils/players", "https://evil": "/utils",
|
||||
"//evil": "/utils", "/api/state": "/utils", "/utils": "/utils", "/utils/login": "/utils"} {
|
||||
if got := safeNext(next); got != want {
|
||||
t.Errorf("safeNext(%q) = %q, want %q", next, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-8
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
// JSON API for game clients other than the htmx page (a Defold client): the same state the
|
||||
// views build and the same actions, run through runAction (server.go). Times are unix
|
||||
// JSON API for the game client (Defold): the state the views build (views.go) and the actions, run through runAction (server.go). Times are unix
|
||||
// milliseconds (null when unset), durations seconds; the client formats. See api.md.
|
||||
|
||||
import (
|
||||
@@ -605,8 +604,8 @@ type APIToast struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// APIFX is what the web client plays effects from (fx.js): the action, the moons it mined
|
||||
// out and what a sale earned.
|
||||
// APIFX is what the client plays effects from: the action, the moons it mined out and what a
|
||||
// sale earned.
|
||||
type APIFX struct {
|
||||
Kind string `json:"kind"`
|
||||
Finished []string `json:"finished"`
|
||||
@@ -632,7 +631,7 @@ func apiServerError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
|
||||
// apiUser finds the player from the X-MM-User header or the mm_user cookie, or creates one
|
||||
// (as POST /session does). It echoes the id in the header and cookie; on a refusal it
|
||||
// (rate-limited per IP). It echoes the id in the header and cookie; on a refusal it
|
||||
// writes the error response and returns "".
|
||||
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
|
||||
ctx := r.Context()
|
||||
@@ -642,7 +641,15 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
|
||||
userID = c.Value
|
||||
}
|
||||
}
|
||||
if !uuidPattern.MatchString(userID) || !userExists(ctx, s.db, userID) {
|
||||
exists := false
|
||||
if uuidPattern.MatchString(userID) {
|
||||
var err error
|
||||
if exists, err = userExists(ctx, s.db, userID); err != nil {
|
||||
apiServerError(w, err)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
if !s.newPlayers.allow(r) {
|
||||
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
|
||||
return "", false
|
||||
@@ -653,7 +660,10 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
|
||||
return "", false
|
||||
}
|
||||
created = true
|
||||
} else if isBanned(ctx, s.db, userID) {
|
||||
} else if banned, err := isBanned(ctx, s.db, userID); err != nil {
|
||||
apiServerError(w, err)
|
||||
return "", false
|
||||
} else if banned {
|
||||
apiError(w, http.StatusForbidden, "This account is suspended")
|
||||
return "", false
|
||||
}
|
||||
@@ -678,6 +688,12 @@ func (s *server) apiPage(ctx context.Context, userID string, v View, get func(st
|
||||
|
||||
// apiPageFor builds the page for a resolved view, with what the server loads for it.
|
||||
func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, error) {
|
||||
if v.Section == "moons" {
|
||||
// The moon view is the only one that shows a description (loadState leaves them out).
|
||||
if err := loadMoonDescription(ctx, s.db, state, v.Moon); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
page := state.page(v)
|
||||
if err := s.loadRanking(ctx, page); err != nil {
|
||||
return nil, err
|
||||
@@ -764,7 +780,7 @@ func (s *server) handleAPIState(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, APIResponse{OK: true, UserID: userID, New: created, State: state})
|
||||
}
|
||||
|
||||
// handleAPIAction is POST /api/act/{action}: runs an action like POST /act/{action} and
|
||||
// handleAPIAction is POST /api/act/{action}: runs an action (runAction) and
|
||||
// answers with the toast and the state after it. A rule violation is a 422 (with the state).
|
||||
func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
|
||||
get, err := apiParams(r)
|
||||
|
||||
+29
-20
@@ -1,9 +1,9 @@
|
||||
# Moon Miners JSON API
|
||||
|
||||
For game clients other than the htmx page (the Defold client). Same state and rules as the
|
||||
web game: `GET /api/state` is built from the same view models as `GET /`, and
|
||||
`POST /api/act/<action>` runs the same code as `POST /act/<action>` (`runAction` in
|
||||
server.go), onboarding gates included. Code: `api.go`.
|
||||
The game's API, for the Defold client. `GET /api/state` is built from the view models
|
||||
(`views.go`), and `POST /api/act/<action>` runs the game rules through `runAction`
|
||||
(server.go), onboarding gates included. Code: `api.go`. "The web" below is the former htmx
|
||||
client: the API keeps its texts and formats.
|
||||
|
||||
Conventions:
|
||||
- Times are unix milliseconds (`null` when unset), durations seconds. `state.now` is the
|
||||
@@ -74,7 +74,7 @@ Core-loop actions and their params:
|
||||
|
||||
| action | params | notes |
|
||||
|---|---|---|
|
||||
| `buy-moon` | `grade` (optional: `D`, `C`, `B`, `A`, `S`; default `state.moonGrade`, the grade of the newest moon, else the lowest) | view moves to the new moon; an unknown grade is a rule error |
|
||||
| `buy-moon` | `grade` (optional: `D`, `C`, `B`, `A`, `S`; default `state.moonGrade`, the grade of the newest moon, else the lowest) | view moves to the new moon; an unknown grade is a rule error. `count` (optional): a moon pack's size (`newMoon.grades[].packs`: 5, 10, 20 once the store is open) buys that many at once, at the pack's price, each moon at least the pack's floor; the view and `reveal` show the best of them |
|
||||
| `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used. Refused ("Nothing left to mine on this well") when the drills already on the moon will take all the tool could get (its deploy card's `here` is 0, `blocked`) |
|
||||
| `claim` | `claim-well` or `claim-moon` or `claim-wide` (moon id), `redeploy=1` | no scope: claim all (needs `unlocked["claim-all"]`); `redeploy` needs `unlocked.redeploy`; a tool whose well is drained or has no room left (as `stake`) stays idle |
|
||||
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well, until it has no room left: each time the smallest that fills what is left alone, else the biggest (as `stake`; the rest stay idle, refused if none fits) |
|
||||
@@ -115,11 +115,14 @@ Responses:
|
||||
|
||||
## GET /api/moon-settings
|
||||
|
||||
The procedural-moon look tuned by admins on `/utils/moons` (no player needed): the saved
|
||||
JSON object, or `null` when none is saved. Merge it over the defaults (`DEFAULTS` in
|
||||
`web/static/js/terrain.js`; unknown keys and wrong types are ignored, see
|
||||
`mergeSettings`), then build each moon from its id like `terrain.js` does. Cache it for the
|
||||
session; the web page also reads it once per page load.
|
||||
The procedural-moon look shared by every moon (no player needed): the saved JSON object, or
|
||||
`null` when none is saved. Merge it over the defaults (`DEFAULTS` in the Defold client's
|
||||
`modules/moon/terrain.lua`; unknown keys and wrong types are ignored, see
|
||||
`merge_settings`), then build each moon from its id. Cache it for the session.
|
||||
|
||||
`POST /api/moon-settings` saves it (a JSON object, up to 64 KB; 204): for a signed-in admin
|
||||
(the `mm_admin` cookie), a player in `ADMIN_USER_IDS` (`X-MM-User`), or anyone with
|
||||
`MM_DEV=1`; others get a 403.
|
||||
|
||||
## State
|
||||
|
||||
@@ -163,14 +166,15 @@ short code, listed with each field, and the web's text next to it.
|
||||
|
||||
```
|
||||
{tools: [{id, storeItemId, name, code, image, yieldLevel, cycleLevel, count, idle, reach, cycleSecs, yield,
|
||||
selected, stake, tracks, resale, noSale, noSaleText, bonus}],
|
||||
selected, stake, tracks, ladderKey, resale, noSale, noSaleText, bonus}],
|
||||
ladders: {<ladderKey>: {yield: [steps], cycle: [steps]}},
|
||||
drill}
|
||||
```
|
||||
|
||||
- Every tool is its own row, copies too: `count` is 1, `idle` 1 when it isn't mining, `stake`
|
||||
its drill.
|
||||
- `tracks`: the tool's two upgrade tracks, yield then cycle: `{kind, name, level, max, value,
|
||||
next, ladder}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds
|
||||
next}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds
|
||||
per cycle); values are a stake's now, with the player's bonuses and live events. Upgrade
|
||||
with `tool=<id>&track=<kind>`.
|
||||
- `next`: the next level, `null` at the top: `{level, value, label, price, parts: [{name,
|
||||
@@ -181,8 +185,10 @@ short code, listed with each field, and the web's text next to it.
|
||||
0). Make it in the Refinery"), `shortText` the line under it when credits are short;
|
||||
`rateFrom`/`rateTo` credits per hour now and after it, `cost` its price plus the parts at
|
||||
sell value, `paybackSecs` null if it never pays back.
|
||||
- `ladder`: every level from 0 `[{level, value, rate, price, parts: [{name, need, have}],
|
||||
current, next}]` (the other track as it is; `price` 0 for level 0).
|
||||
- `ladders`: each track's ladder, sent once for every tool with the same store item and
|
||||
levels: `ladders[ladderKey][kind]` (`ladderKey` is `"<storeItemId>:<yieldLevel>:<cycleLevel>"`)
|
||||
is every level from 0 `[{level, value, rate, price, parts: [{name, need, have}], current,
|
||||
next}]` (the other track as it is; `price` 0 for level 0).
|
||||
- `resale`: what it sells for (its price and its levels' prices × `rules.equipmentResale`);
|
||||
`noSale`: `only-tool`, `claim-first`, `mining` or `""`, `noSaleText` its text ("Claim its
|
||||
haul first").
|
||||
@@ -294,7 +300,9 @@ button), `credits` ("Need ₥X more", the button disabled) or `""`.
|
||||
|
||||
`{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId,
|
||||
moonName, wellId, resource, supply, credits, perMin, idle, reason}]}`: up to 3 wells to
|
||||
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first;
|
||||
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first; `credits`
|
||||
is a cycle's take after the drills already on the moon, and wells where it would get nothing
|
||||
(stake refused) are left out (`allEmpty` when none is left);
|
||||
`reason` `best-value`, `most-supply`, `no-drill` or `shared`, `reasonText` its text
|
||||
("Highest value").
|
||||
|
||||
@@ -449,11 +457,12 @@ From a live server (long ids and arrays shortened).
|
||||
"tracks": [{"kind": "yield", "name": "Twin rotors", "level": 1, "max": 4, "value": 5,
|
||||
"next": {"level": 2, "value": 6, "label": "Twin rotors 2", "price": 320, "parts": [],
|
||||
"canAfford": true, "blocked": "", "short": 0, "blockedText": "", "shortText": "",
|
||||
"rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7},
|
||||
"ladder": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false},
|
||||
{"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"]},
|
||||
{"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…", "ladder": ["…"]}],
|
||||
"resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}],
|
||||
"rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7}},
|
||||
{"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…"}],
|
||||
"ladderKey": "1:1:2", "resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}],
|
||||
"ladders": {"1:1:2": {"yield": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false},
|
||||
{"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"],
|
||||
"cycle": ["…"]}},
|
||||
"drill": {"toolId": "13b3…", "mining": false, "resale": 980, "noSale": "", "noSaleText": "",
|
||||
"confirm": {"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok": "Sell for ₥980", "danger": true},
|
||||
"idleSame": 0, "idleBest": "", "deployTo": null}}
|
||||
|
||||
+46
-9
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
@@ -304,6 +305,9 @@ type APILiveEvent struct {
|
||||
|
||||
type APIEquipment struct {
|
||||
Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped
|
||||
// The upgrade ladders by ladderKey (store item and levels: every tool with those has
|
||||
// the same), each track's by kind; a tool row names its own (ladderKey).
|
||||
Ladders map[string]map[string][]APILadderStep `json:"ladders"`
|
||||
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
|
||||
}
|
||||
|
||||
@@ -323,6 +327,7 @@ type APIToolRow struct {
|
||||
Selected bool `json:"selected"`
|
||||
Stake *APIStake `json:"stake"` // the shown copy's drill
|
||||
Tracks []APITrack `json:"tracks"` // yield, then cycle
|
||||
LadderKey string `json:"ladderKey"` // its tracks' ladders in equipment.ladders
|
||||
Resale float64 `json:"resale"`
|
||||
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
|
||||
NoSaleText string `json:"noSaleText"`
|
||||
@@ -338,7 +343,6 @@ type APITrack struct {
|
||||
Max int `json:"max"`
|
||||
Value float64 `json:"value"`
|
||||
Next *APINextLevel `json:"next"` // null at the top level
|
||||
Ladder []APILadderStep `json:"ladder"`
|
||||
}
|
||||
|
||||
type APINextLevel struct {
|
||||
@@ -399,11 +403,7 @@ func apiParts(rows []PartRow) []APIPart {
|
||||
func apiTracks(s *State, rows []TrackRow) []APITrack {
|
||||
out := []APITrack{}
|
||||
for _, r := range rows {
|
||||
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value, Ladder: []APILadderStep{}}
|
||||
for _, st := range r.Ladder {
|
||||
t.Ladder = append(t.Ladder, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
|
||||
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
|
||||
}
|
||||
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value}
|
||||
if n := r.Next; n != nil {
|
||||
next := &APINextLevel{Level: r.Level + 1, Value: r.NextValue, Label: r.Label(), Price: n.Price,
|
||||
Parts: apiParts(r.Parts), CanAfford: r.CanAfford, Short: max(0, n.Price-s.Balance),
|
||||
@@ -427,15 +427,38 @@ func apiTracks(s *State, rows []TrackRow) []APITrack {
|
||||
return out
|
||||
}
|
||||
|
||||
// ladderKey names a tool's ladders: "<store item>:<yield level>:<cycle level>".
|
||||
func ladderKey(storeItemID int, lv Levels) string {
|
||||
return fmt.Sprintf("%d:%d:%d", storeItemID, lv.Yield, lv.Cycle)
|
||||
}
|
||||
|
||||
// apiLadders are the item's ladders at the levels, by kind (the tracks it has).
|
||||
func apiLadders(s *State, item *StoreItem, lv Levels, tracks []TrackRow) map[string][]APILadderStep {
|
||||
out := map[string][]APILadderStep{}
|
||||
for _, r := range tracks {
|
||||
steps := []APILadderStep{}
|
||||
for _, st := range s.trackLadder(item, lv, r.Kind) {
|
||||
steps = append(steps, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
|
||||
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
|
||||
}
|
||||
out[r.Kind] = steps
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiEquipment(p *Page) *APIEquipment {
|
||||
s := p.S
|
||||
b := s.Bonus()
|
||||
e := &APIEquipment{Tools: []APIToolRow{}}
|
||||
e := &APIEquipment{Tools: []APIToolRow{}, Ladders: map[string]map[string][]APILadderStep{}}
|
||||
for _, row := range p.Equipment {
|
||||
key := ladderKey(row.StoreItemID, row.Levels())
|
||||
if _, ok := e.Ladders[key]; !ok {
|
||||
e.Ladders[key] = apiLadders(s, row.Item, row.Levels(), row.Tracks)
|
||||
}
|
||||
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
|
||||
Image: itemImages + row.Item.Image, YieldLevel: row.YieldLevel, CycleLevel: row.CycleLevel,
|
||||
Count: row.Count, Idle: row.Idle, Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield,
|
||||
Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), Resale: row.Item.ToolResale(row.Levels()),
|
||||
Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), LadderKey: key, Resale: row.Item.ToolResale(row.Levels()),
|
||||
NoSale: s.noSale(row), NoSaleText: noSaleText[s.noSale(row)], Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}}
|
||||
if row.Stake != nil {
|
||||
st := apiStake(*row.Stake, false)
|
||||
@@ -851,13 +874,27 @@ type APIMoonGrade struct {
|
||||
SupplyMax float64 `json:"supplyMax"`
|
||||
Default bool `json:"default"` // what buy-moon without a grade buys
|
||||
WhyText string `json:"whyText"` // why its buy button is disabled, "" if it isn't
|
||||
// Moon packs (once the store is open): buy-moon with grade=<grade>&count=<count>.
|
||||
Packs []APIMoonPack `json:"packs"`
|
||||
}
|
||||
|
||||
// APIMoonPack is count moons of a grade bought at once, for price (discount off; each moon
|
||||
// at least a little better than a single one's floor).
|
||||
type APIMoonPack struct {
|
||||
Count int `json:"count"`
|
||||
Price float64 `json:"price"`
|
||||
Discount float64 `json:"discount"`
|
||||
CanAfford bool `json:"canAfford"`
|
||||
}
|
||||
|
||||
func apiMoonGrades(gs []GradeOffer) []APIMoonGrade {
|
||||
out := []APIMoonGrade{}
|
||||
for _, g := range gs {
|
||||
a := APIMoonGrade{Grade: g.Grade, Price: g.Price, CanAfford: g.CanAfford, WellsMin: g.WellsMin, WellsMax: g.WellsMax,
|
||||
SupplyMin: g.SupplyMin, SupplyMax: g.SupplyMax, Default: g.Default}
|
||||
SupplyMin: g.SupplyMin, SupplyMax: g.SupplyMax, Default: g.Default, Packs: []APIMoonPack{}}
|
||||
for _, p := range g.Packs {
|
||||
a.Packs = append(a.Packs, APIMoonPack{Count: p.Count, Price: p.Price, Discount: p.Discount, CanAfford: p.CanAfford})
|
||||
}
|
||||
if !g.CanAfford {
|
||||
a.WhyText = "Not enough credits"
|
||||
}
|
||||
|
||||
+1
-1
@@ -204,7 +204,7 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
|
||||
}
|
||||
for _, tr := range r.Tracks {
|
||||
if n := tr.Next; n != nil && ((n.Blocked != "") != (n.BlockedText != "") || n.Label == "") ||
|
||||
len(tr.Ladder) != tr.Max+1 {
|
||||
len(e.Ladders[r.LadderKey][tr.Kind]) != tr.Max+1 {
|
||||
fail("track texts", tr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// For("all") counts the "all" event once (cycle events are always saved as "all").
|
||||
func TestPriceBoostsFor(t *testing.T) {
|
||||
b := PriceBoosts{"all": 2, "iron": 1.5}
|
||||
for name, want := range map[string]float64{"all": 2, "iron": 3, "gold": 2} {
|
||||
if got := b.For(name); got != want {
|
||||
t.Errorf("For(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
if got := (PriceBoosts{}).For("all"); got != 1 {
|
||||
t.Errorf("empty For(all) = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
package main
|
||||
|
||||
// Small server-drawn SVG charts for the admin pages (the Economy page's progression
|
||||
// charts): line series with a point per value and a tooltip (<title>) on each, a linear or
|
||||
// log y axis, shaded x bands, vertical markers, tick marks under the plot and a strip of
|
||||
// ranges. Colors come from CSS classes (admin.css, "charts"), so both themes work.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Chart struct {
|
||||
Title string
|
||||
XLabel, YLabel string
|
||||
LogY, LogX bool
|
||||
XTickVals []float64 // x ticks; default: about 8 even steps
|
||||
YFmt func(float64) string // tick labels; default %g
|
||||
XFmt func(float64) string
|
||||
Series []ChartSeries
|
||||
Bands []ChartBand // shaded x ranges with a label at the top
|
||||
VLines []ChartVLine // vertical markers
|
||||
Ticks []ChartTick // marks under the plot (one row per series)
|
||||
Strip []ChartRange // ranges in a strip under the plot
|
||||
StripLabel string
|
||||
Width, Height float64 // of the plot area's viewBox; defaults 1000×260
|
||||
NoPoints bool // lines only
|
||||
}
|
||||
|
||||
type ChartSeries struct {
|
||||
Name string
|
||||
Class string // ch-saved, ch-draft
|
||||
Points []ChartPoint
|
||||
}
|
||||
|
||||
type ChartPoint struct {
|
||||
X, Y float64
|
||||
Title string // tooltip
|
||||
Mark bool // drawn as a jump marker
|
||||
Label string // short text drawn next to the point
|
||||
Alt bool // drawn as a hollow diamond, off the line
|
||||
}
|
||||
|
||||
type ChartBand struct {
|
||||
X0, X1 float64
|
||||
Label string
|
||||
}
|
||||
|
||||
type ChartVLine struct {
|
||||
X float64
|
||||
Class string
|
||||
Label string
|
||||
Title string
|
||||
}
|
||||
|
||||
type ChartTick struct {
|
||||
X float64
|
||||
Row int // series index
|
||||
Class string
|
||||
Title string
|
||||
}
|
||||
|
||||
type ChartRange struct {
|
||||
X0, X1 float64
|
||||
Row int
|
||||
Class string
|
||||
Title string
|
||||
}
|
||||
|
||||
const (
|
||||
chartLeft = 64.0
|
||||
chartRight = 14.0
|
||||
chartTop = 30.0
|
||||
chartBottom = 34.0
|
||||
)
|
||||
|
||||
// skip: a point the axes can't place.
|
||||
func (ch *Chart) skip(p ChartPoint) bool {
|
||||
return ch.LogY && p.Y <= 0 || ch.LogX && p.X <= 0 || math.IsInf(p.Y, 0) || math.IsNaN(p.Y)
|
||||
}
|
||||
|
||||
// SVG draws the chart. An empty chart draws a note instead.
|
||||
func (ch *Chart) SVG() template.HTML {
|
||||
w, h := ch.Width, ch.Height
|
||||
if w == 0 {
|
||||
w = 1000
|
||||
}
|
||||
if h == 0 {
|
||||
h = 260
|
||||
}
|
||||
tickRows := 0
|
||||
for _, t := range ch.Ticks {
|
||||
tickRows = max(tickRows, t.Row+1)
|
||||
}
|
||||
stripRows := 0
|
||||
for _, r := range ch.Strip {
|
||||
stripRows = max(stripRows, r.Row+1)
|
||||
}
|
||||
extra := float64(tickRows)*8 + float64(stripRows)*8
|
||||
if tickRows > 0 || stripRows > 0 {
|
||||
extra += 6
|
||||
}
|
||||
total := h + extra
|
||||
|
||||
// Ranges.
|
||||
x0, x1 := math.Inf(1), math.Inf(-1)
|
||||
y0, y1 := math.Inf(1), math.Inf(-1)
|
||||
for _, s := range ch.Series {
|
||||
for _, p := range s.Points {
|
||||
if ch.skip(p) {
|
||||
continue
|
||||
}
|
||||
x0, x1 = min(x0, p.X), max(x1, p.X)
|
||||
y0, y1 = min(y0, p.Y), max(y1, p.Y)
|
||||
}
|
||||
}
|
||||
for _, b := range ch.Bands {
|
||||
x0, x1 = min(x0, b.X0), max(x1, b.X1)
|
||||
}
|
||||
if math.IsInf(x0, 0) || math.IsInf(y0, 0) {
|
||||
return template.HTML(`<p class="muted small">No data.</p>`)
|
||||
}
|
||||
if x1 == x0 {
|
||||
x1 = x0 + 1
|
||||
}
|
||||
if ch.LogY {
|
||||
y0 = math.Pow(10, math.Floor(math.Log10(y0)))
|
||||
y1 = math.Pow(10, math.Ceil(math.Log10(y1)))
|
||||
if y1 == y0 {
|
||||
y1 = y0 * 10
|
||||
}
|
||||
} else {
|
||||
y0 = min(0, y0)
|
||||
if y1 <= y0 {
|
||||
y1 = y0 + 1
|
||||
}
|
||||
y1 = niceCeil(y1)
|
||||
}
|
||||
pw, ph := w-chartLeft-chartRight, h-chartTop-chartBottom
|
||||
px := func(x float64) float64 {
|
||||
if ch.LogX {
|
||||
return chartLeft + (math.Log10(max(x, x0))-math.Log10(x0))/(math.Log10(x1)-math.Log10(x0))*pw
|
||||
}
|
||||
return chartLeft + (x-x0)/(x1-x0)*pw
|
||||
}
|
||||
py := func(y float64) float64 {
|
||||
if ch.LogY {
|
||||
return chartTop + ph - (math.Log10(y)-math.Log10(y0))/(math.Log10(y1)-math.Log10(y0))*ph
|
||||
}
|
||||
return chartTop + ph - (y-y0)/(y1-y0)*ph
|
||||
}
|
||||
yf := ch.YFmt
|
||||
if yf == nil {
|
||||
yf = func(v float64) string { return fmt.Sprintf("%g", v) }
|
||||
}
|
||||
xf := ch.XFmt
|
||||
if xf == nil {
|
||||
xf = func(v float64) string { return fmt.Sprintf("%g", v) }
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
esc := template.HTMLEscapeString
|
||||
fmt.Fprintf(&b, `<svg class="chart" viewBox="0 0 %.0f %.0f" role="img" aria-label="%s">`, w, total, esc(ch.Title))
|
||||
// Bands.
|
||||
for i, band := range ch.Bands {
|
||||
if i%2 == 0 {
|
||||
fmt.Fprintf(&b, `<rect class="ch-band" x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>`,
|
||||
px(band.X0), chartTop, max(0, px(band.X1)-px(band.X0)), ph)
|
||||
}
|
||||
fmt.Fprintf(&b, `<text class="ch-band-label" x="%.1f" y="%.1f">%s</text>`, px(band.X0)+3, chartTop-4, esc(band.Label))
|
||||
}
|
||||
// Y grid and labels.
|
||||
for _, v := range yTicks(y0, y1, ch.LogY) {
|
||||
y := py(v)
|
||||
fmt.Fprintf(&b, `<line class="ch-grid" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/>`, chartLeft, chartLeft+pw, y, y)
|
||||
fmt.Fprintf(&b, `<text class="ch-ylab" x="%.1f" y="%.1f">%s</text>`, chartLeft-6, y+3.5, esc(yf(v)))
|
||||
}
|
||||
// X axis and labels.
|
||||
fmt.Fprintf(&b, `<line class="ch-axis" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/>`, chartLeft, chartLeft+pw, chartTop+ph, chartTop+ph)
|
||||
xt := ch.XTickVals
|
||||
if xt == nil {
|
||||
xt = xTicks(x0, x1)
|
||||
}
|
||||
for _, v := range xt {
|
||||
if v < x0 || v > x1 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, `<text class="ch-xlab" x="%.1f" y="%.1f">%s</text>`, px(v), chartTop+ph+13, esc(xf(v)))
|
||||
}
|
||||
if ch.XLabel != "" {
|
||||
fmt.Fprintf(&b, `<text class="ch-title" x="%.1f" y="%.1f" text-anchor="end">%s</text>`, chartLeft+pw, chartTop+ph+27, esc(ch.XLabel))
|
||||
}
|
||||
if ch.YLabel != "" {
|
||||
fmt.Fprintf(&b, `<text class="ch-title" x="%.1f" y="%.1f">%s</text>`, 4.0, 10.0, esc(ch.YLabel))
|
||||
}
|
||||
// Vertical markers.
|
||||
for _, v := range ch.VLines {
|
||||
x := px(v.X)
|
||||
fmt.Fprintf(&b, `<g class="ch-vline %s"><title>%s</title><line x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/><text x="%.1f" y="%.1f">%s</text></g>`,
|
||||
esc(v.Class), esc(v.Title), x, x, chartTop, chartTop+ph, x+2, chartTop+9, esc(v.Label))
|
||||
}
|
||||
// Series: lines, then points.
|
||||
for _, s := range ch.Series {
|
||||
var d strings.Builder
|
||||
for _, p := range s.Points {
|
||||
if p.Alt || ch.skip(p) {
|
||||
continue
|
||||
}
|
||||
if d.Len() == 0 {
|
||||
d.WriteString("M")
|
||||
} else {
|
||||
d.WriteString("L")
|
||||
}
|
||||
fmt.Fprintf(&d, "%.1f %.1f", px(p.X), py(p.Y))
|
||||
}
|
||||
fmt.Fprintf(&b, `<path class="ch-line %s" d="%s"/>`, esc(s.Class), d.String())
|
||||
}
|
||||
for _, s := range ch.Series {
|
||||
fmt.Fprintf(&b, `<g class="%s">`, esc(s.Class))
|
||||
for _, p := range s.Points {
|
||||
if ch.skip(p) {
|
||||
continue
|
||||
}
|
||||
x, y := px(p.X), py(p.Y)
|
||||
switch {
|
||||
case p.Alt:
|
||||
fmt.Fprintf(&b, `<path class="ch-alt" d="M%.1f %.1fl4.5 4.5-4.5 4.5-4.5-4.5z"><title>%s</title></path>`, x, y-4.5, esc(p.Title))
|
||||
case p.Mark:
|
||||
fmt.Fprintf(&b, `<g class="ch-jump"><title>%s</title><circle cx="%.1f" cy="%.1f" r="7"/><circle class="ch-pt" cx="%.1f" cy="%.1f" r="3"/></g>`,
|
||||
esc(p.Title), x, y, x, y)
|
||||
case !ch.NoPoints:
|
||||
fmt.Fprintf(&b, `<circle class="ch-pt" cx="%.1f" cy="%.1f" r="3"><title>%s</title></circle>`, x, y, esc(p.Title))
|
||||
}
|
||||
if p.Label != "" {
|
||||
fmt.Fprintf(&b, `<text class="ch-plab" x="%.1f" y="%.1f">%s</text>`, x+6, y-6, esc(p.Label))
|
||||
}
|
||||
}
|
||||
b.WriteString(`</g>`)
|
||||
}
|
||||
// Ticks and strip under the plot.
|
||||
under := h + 2
|
||||
for _, t := range ch.Ticks {
|
||||
x, y := px(t.X), under+float64(t.Row)*8
|
||||
fmt.Fprintf(&b, `<line class="ch-tick %s" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"><title>%s</title></line>`,
|
||||
esc(t.Class), x, x, y, y+6, esc(t.Title))
|
||||
}
|
||||
under += float64(tickRows) * 8
|
||||
if tickRows > 0 {
|
||||
under += 2
|
||||
}
|
||||
for _, r := range ch.Strip {
|
||||
y := under + float64(r.Row)*8
|
||||
fmt.Fprintf(&b, `<rect class="ch-range %s" x="%.1f" y="%.1f" width="%.1f" height="6"><title>%s</title></rect>`,
|
||||
esc(r.Class), px(r.X0), y, max(1.5, px(r.X1)-px(r.X0)), esc(r.Title))
|
||||
}
|
||||
b.WriteString(`</svg>`)
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
// Legend is the chart's series names with their swatches.
|
||||
func (ch *Chart) Legend() template.HTML {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<span class="ch-legend">`)
|
||||
for _, s := range ch.Series {
|
||||
fmt.Fprintf(&b, `<span class="%s"><i class="ch-swatch"></i>%s</span>`, template.HTMLEscapeString(s.Class), template.HTMLEscapeString(s.Name))
|
||||
}
|
||||
b.WriteString(`</span>`)
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
func niceCeil(v float64) float64 {
|
||||
if v <= 0 {
|
||||
return 1
|
||||
}
|
||||
p := math.Pow(10, math.Floor(math.Log10(v)))
|
||||
for _, m := range []float64{1, 2, 2.5, 5, 10} {
|
||||
if m*p >= v {
|
||||
return m * p
|
||||
}
|
||||
}
|
||||
return 10 * p
|
||||
}
|
||||
|
||||
func yTicks(y0, y1 float64, log bool) []float64 {
|
||||
var out []float64
|
||||
if log {
|
||||
decades := math.Log10(y1) - math.Log10(y0)
|
||||
for e := math.Log10(y0); e <= math.Log10(y1)+1e-9; e++ {
|
||||
v := math.Pow(10, math.Round(e))
|
||||
out = append(out, v)
|
||||
if decades <= 3 && e+1 <= math.Log10(y1)+1e-9 {
|
||||
out = append(out, 3*v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
step := niceCeil((y1 - y0) / 5)
|
||||
for v := y0; v <= y1+step*1e-9; v += step {
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func xTicks(x0, x1 float64) []float64 {
|
||||
step := niceCeil((x1 - x0) / 8)
|
||||
var out []float64
|
||||
for v := math.Ceil(x0/step) * step; v <= x1+step*1e-9; v += step {
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
package main
|
||||
|
||||
// Claim-all against a real database: TestClaimGolden plays fixed scenarios (one-well and
|
||||
// multi-well drills on shared wells, wells emptied mid-claim, redeploy on and off, every
|
||||
// scope, reach 1, 3 and 5) and compares what they leave in the database with
|
||||
// testdata/claim_golden.txt; TestClaimAllPerf times claim-all and counts its statements.
|
||||
// Both need MM_TEST_DATABASE, a migrated database of their own (scripts/wt makes one:
|
||||
// mm_<task>); without it they are skipped. MM_CLAIM_GOLDEN_WRITE=1 rewrites the golden file.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// countTracer counts the statements a pool sends and keeps the slowest.
|
||||
type countTracer struct {
|
||||
n atomic.Int64
|
||||
mu sync.Mutex
|
||||
slow []string
|
||||
}
|
||||
|
||||
type traceStart struct {
|
||||
sql string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (c *countTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, d pgx.TraceQueryStartData) context.Context {
|
||||
c.n.Add(1)
|
||||
return context.WithValue(ctx, c, traceStart{d.SQL, time.Now()})
|
||||
}
|
||||
func (c *countTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryEndData) {
|
||||
if s, ok := ctx.Value(c).(traceStart); ok {
|
||||
if d := time.Since(s.at); d > 10*time.Millisecond {
|
||||
c.mu.Lock()
|
||||
c.slow = append(c.slow, fmt.Sprintf("%v %s", d.Round(time.Millisecond), strings.Join(strings.Fields(s.sql), " ")))
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
func (c *countTracer) TraceBatchStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceBatchStartData) context.Context {
|
||||
return ctx
|
||||
}
|
||||
func (c *countTracer) TraceBatchQuery(context.Context, *pgx.Conn, pgx.TraceBatchQueryData) {
|
||||
c.n.Add(1)
|
||||
}
|
||||
func (c *countTracer) TraceBatchEnd(context.Context, *pgx.Conn, pgx.TraceBatchEndData) {}
|
||||
|
||||
func testDB(t *testing.T) (*pgxpool.Pool, *countTracer) {
|
||||
t.Helper()
|
||||
name := os.Getenv("MM_TEST_DATABASE")
|
||||
if name == "" {
|
||||
t.Skip("MM_TEST_DATABASE not set")
|
||||
}
|
||||
if name == "moonminers" {
|
||||
t.Fatal("MM_TEST_DATABASE must not be the game's database")
|
||||
}
|
||||
if !loadDotEnv(".env") {
|
||||
loadDotEnv("../.env")
|
||||
}
|
||||
os.Setenv("DB_DATABASE", name)
|
||||
pc, err := pgxpool.ParseConfig(appDSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := &countTracer{}
|
||||
pc.ConnConfig.Tracer = tr
|
||||
db, err := pgxpool.NewWithConfig(context.Background(), pc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
return db, tr
|
||||
}
|
||||
|
||||
// fx builds players straight in the database and names every row it makes (labels), so
|
||||
// what a claim leaves can be compared across runs.
|
||||
type fx struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
db *pgxpool.Pool
|
||||
run uint32 // the first part of the ids the fixture makes (orders them as made)
|
||||
ids int
|
||||
label map[string]string // id → label
|
||||
res map[string]int // resource name → id
|
||||
resN []string // resource names, sorted
|
||||
clock int // seconds after base for the next stake
|
||||
action int
|
||||
}
|
||||
|
||||
var fxBase = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func newFx(t *testing.T, db *pgxpool.Pool) *fx {
|
||||
f := &fx{t: t, ctx: context.Background(), db: db, label: map[string]string{}, res: map[string]int{},
|
||||
run: rand.Uint32()}
|
||||
rows, err := db.Query(f.ctx, "select id, name from resource order by name")
|
||||
f.must(err)
|
||||
var id int
|
||||
var n string
|
||||
_, err = pgx.ForEachRow(rows, []any{&id, &n}, func() error {
|
||||
f.res[n] = id
|
||||
f.resN = append(f.resN, n)
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
if len(f.resN) < 5 {
|
||||
t.Fatalf("resources: %v", f.resN)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fx) must(err error) {
|
||||
f.t.Helper()
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fx) id(q string, args ...any) string {
|
||||
f.t.Helper()
|
||||
var id string
|
||||
f.must(f.db.QueryRow(f.ctx, q, args...).Scan(&id))
|
||||
return id
|
||||
}
|
||||
|
||||
func (f *fx) user(label string, onboarding []string, advisors ...string) string {
|
||||
if onboarding == nil {
|
||||
onboarding = []string{}
|
||||
}
|
||||
u := f.id("insert into users(name, onboarding) values ($1, $2) returning id::text", label, onboarding)
|
||||
f.label[u] = label
|
||||
f.t.Cleanup(func() {
|
||||
f.db.Exec(f.ctx, "delete from event_log where user_id = $1", u)
|
||||
f.db.Exec(f.ctx, "delete from users where id = $1", u)
|
||||
})
|
||||
_, err := f.db.Exec(f.ctx, "insert into bank_account(user_id, balance) values ($1, 1000)", u)
|
||||
f.must(err)
|
||||
_, err = f.db.Exec(f.ctx, `insert into resource_account(resource_id, user_id, balance)
|
||||
select id, $1, 0.1 * id from resource`, u)
|
||||
f.must(err)
|
||||
for i, a := range advisors {
|
||||
_, err = f.db.Exec(f.ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", u, a, i+1)
|
||||
f.must(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// moon makes a moon with wells of the given resources (indexes into resN) and supplies;
|
||||
// the wells are labelled <moon>.<i>.
|
||||
func (f *fx) moon(user, label string, res []int, supply []float64) []string {
|
||||
m := f.id(`insert into staking_source(name, description, user_id, address, grade)
|
||||
values ($1, '', $2, '', 'C') returning id::text`, label, user)
|
||||
f.label[m] = label
|
||||
var wells []string
|
||||
for i, r := range res {
|
||||
f.ids++
|
||||
w := f.id("insert into resource_well(id, resource_id, source_id, supply) values ($1, $2, $3, $4) returning id::text",
|
||||
fmt.Sprintf("%08x-0000-4000-8000-%012x", f.run, f.ids), f.res[f.resN[r%len(f.resN)]], m, supply[i])
|
||||
f.label[w] = fmt.Sprintf("%s.%d", label, i)
|
||||
wells = append(wells, w)
|
||||
}
|
||||
return wells
|
||||
}
|
||||
|
||||
func (f *fx) tool(user, label string, item, yl, cl int) string {
|
||||
id := f.id("insert into inventory_item(user_id, store_item_id, yield_level, cycle_level) values ($1, $2, $3, $4) returning id::text",
|
||||
user, item, yl, cl)
|
||||
f.label[id] = label
|
||||
return id
|
||||
}
|
||||
|
||||
// stake puts a stake in: ready (started at the next tick of the fixture clock, long ago)
|
||||
// or still running.
|
||||
func (f *fx) stake(user, label, tool, well string, amount float64, reach int, ready bool) string {
|
||||
f.clock++
|
||||
at, secs := fxBase.Add(time.Duration(f.clock)*time.Second), 30.0
|
||||
if !ready {
|
||||
at, secs = time.Now().Add(time.Duration(f.clock)*time.Second), 1e8
|
||||
}
|
||||
id := f.id(`insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
|
||||
values ($1, $2, $3, $4, $5, $6, $7) returning id::text`, user, well, tool, secs, amount, reach, at)
|
||||
f.label[id] = label
|
||||
return id
|
||||
}
|
||||
|
||||
// finish makes every running stake of the player ready, in the order of their tools' labels
|
||||
// (deterministic, unlike the random ids that order stakes started together).
|
||||
func (f *fx) finish(user string) {
|
||||
rows, err := f.db.Query(f.ctx, `select id::text, inventory_item_id::text from staking_event
|
||||
where user_id = $1 and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`, user)
|
||||
f.must(err)
|
||||
type st struct{ ID, Tool string }
|
||||
stakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[st])
|
||||
f.must(err)
|
||||
sort.Slice(stakes, func(i, j int) bool { return f.label[stakes[i].Tool] < f.label[stakes[j].Tool] })
|
||||
for _, s := range stakes {
|
||||
f.clock++
|
||||
_, err := f.db.Exec(f.ctx, "update staking_event set created_at = $2, duration_in_secs = least(duration_in_secs, 30) where id = $1",
|
||||
s.ID, fxBase.Add(time.Duration(f.clock)*time.Second))
|
||||
f.must(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fnum(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
|
||||
|
||||
// claim runs claimReady and writes the result and the player's whole state to out.
|
||||
func (f *fx) claim(out *strings.Builder, user string, scope ClaimScope, what string) {
|
||||
f.action++
|
||||
var lastLog int64
|
||||
f.must(f.db.QueryRow(f.ctx, "select coalesce(max(id), 0) from event_log where user_id = $1", user).Scan(&lastLog))
|
||||
res, err := claimReady(f.ctx, f.db, user, scope)
|
||||
fmt.Fprintf(out, "== %s %s: %s\n", f.label[user], what, f.lbl(scope.MoonID+scope.WellID))
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "error: %v\n", err)
|
||||
} else {
|
||||
var fin []string
|
||||
for _, m := range res.Finished {
|
||||
fin = append(fin, f.label[m.ID]+"="+m.Name)
|
||||
}
|
||||
fmt.Fprintf(out, "claimed %d redeployed %d total %s stopped %d finished %v\n",
|
||||
res.Claimed, res.Redeployed, fnum(res.Total), res.Stopped, fin)
|
||||
}
|
||||
f.dump(out, user, lastLog)
|
||||
}
|
||||
|
||||
func (f *fx) lbl(id string) string {
|
||||
if l, ok := f.label[id]; ok {
|
||||
return l
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (f *fx) dump(out *strings.Builder, user string, lastLog int64) {
|
||||
q := func(sql string, args ...any) pgx.Rows {
|
||||
rows, err := f.db.Query(f.ctx, sql, args...)
|
||||
f.must(err)
|
||||
return rows
|
||||
}
|
||||
// Name the stakes this action made: tool@action.
|
||||
rows := q("select id::text, inventory_item_id::text from staking_event where user_id = $1", user)
|
||||
var id, tool string
|
||||
_, err := pgx.ForEachRow(rows, []any{&id, &tool}, func() error {
|
||||
if _, ok := f.label[id]; !ok {
|
||||
f.label[id] = fmt.Sprintf("%s@%d", f.label[tool], f.action)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
|
||||
var lines []string
|
||||
rows = q(`select resource_well.id::text, supply from resource_well
|
||||
join staking_source on staking_source.id = source_id where user_id = $1`, user)
|
||||
var supply float64
|
||||
_, err = pgx.ForEachRow(rows, []any{&id, &supply}, func() error {
|
||||
lines = append(lines, fmt.Sprintf("well %s %s", f.lbl(id), fnum(supply)))
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
sort.Strings(lines)
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
|
||||
lines = nil
|
||||
rows = q(`select resource.name, balance from resource_account join resource on resource.id = resource_id
|
||||
where user_id = $1`, user)
|
||||
var name string
|
||||
var bal float64
|
||||
_, err = pgx.ForEachRow(rows, []any{&name, &bal}, func() error {
|
||||
lines = append(lines, fmt.Sprintf("balance %s %s", name, fnum(bal)))
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
sort.Strings(lines)
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
|
||||
var flags []string
|
||||
f.must(f.db.QueryRow(f.ctx, "select onboarding from users where id = $1", user).Scan(&flags))
|
||||
slices.Sort(flags)
|
||||
fmt.Fprintf(out, "onboarding %v\n", flags)
|
||||
|
||||
lines = nil
|
||||
rows = q(`select staking_event.id::text, well_id::text, inventory_item_id::text, duration_in_secs, stake_amount,
|
||||
staking_event.reach, claim_event.claim_amount,
|
||||
coalesce((select string_agg(cw.well_id::text || '=' || cw.amount::text, ',') from claim_well cw
|
||||
where cw.claim_event_id = claim_event.id), '')
|
||||
from staking_event left join claim_event on claim_event.staking_event_id = staking_event.id
|
||||
where staking_event.user_id = $1`, user)
|
||||
var well string
|
||||
var dur, amount float64
|
||||
var reach int
|
||||
var claim *float64
|
||||
var cw string
|
||||
_, err = pgx.ForEachRow(rows, []any{&id, &well, &tool, &dur, &amount, &reach, &claim, &cw}, func() error {
|
||||
c := "running"
|
||||
if claim != nil {
|
||||
c = "claimed " + fnum(*claim)
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range strings.Split(cw, ",") {
|
||||
if w, a, ok := strings.Cut(p, "="); ok {
|
||||
parts = append(parts, f.lbl(w)+"="+a)
|
||||
}
|
||||
}
|
||||
slices.Sort(parts)
|
||||
lines = append(lines, fmt.Sprintf("stake %s well %s tool %s secs %s amount %s reach %d %s %v",
|
||||
f.lbl(id), f.lbl(well), f.lbl(tool), fnum(dur), fnum(amount), reach, c, parts))
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
sort.Strings(lines)
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
|
||||
// The event log in order; runs of stake_stopped (ordered by start time only, ties
|
||||
// at random) sorted.
|
||||
lines = nil
|
||||
rows = q("select kind, data::text from event_log where user_id = $1 and id > $2 order by id", user, lastLog)
|
||||
var kind, data string
|
||||
var run []string
|
||||
flush := func() {
|
||||
slices.Sort(run)
|
||||
lines = append(lines, run...)
|
||||
run = nil
|
||||
}
|
||||
_, err = pgx.ForEachRow(rows, []any{&kind, &data}, func() error {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &m); err != nil {
|
||||
return err
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
var b strings.Builder
|
||||
for _, k := range keys {
|
||||
v := m[k]
|
||||
if s, ok := v.(string); ok {
|
||||
v = f.lbl(s)
|
||||
} else if x, ok := v.(float64); ok {
|
||||
v = fnum(x)
|
||||
}
|
||||
fmt.Fprintf(&b, " %s=%v", k, v)
|
||||
}
|
||||
line := "log " + kind + b.String()
|
||||
if kind == "stake_stopped" {
|
||||
run = append(run, line)
|
||||
return nil
|
||||
}
|
||||
flush()
|
||||
lines = append(lines, line)
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
flush()
|
||||
if len(lines) > 0 {
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// withConfig runs fn with the store items' reach replaced (item id → reach).
|
||||
func withReach(reach map[int]int, fn func()) {
|
||||
old := cfg()
|
||||
c := *old
|
||||
c.StoreItems = slices.Clone(old.StoreItems)
|
||||
for i := range c.StoreItems {
|
||||
if r, ok := reach[c.StoreItems[i].ID]; ok {
|
||||
c.StoreItems[i].Reach = r
|
||||
}
|
||||
}
|
||||
effective.Store(&c)
|
||||
defer effective.Store(old)
|
||||
fn()
|
||||
}
|
||||
|
||||
// seqStakeIDs makes new staking_event ids come from a sequence, in the order the stakes
|
||||
// are made, whether the database makes them (the column default) or the server
|
||||
// (newStakeID): stakes started together are ordered by id, which is otherwise random.
|
||||
func seqStakeIDs(t *testing.T, db *pgxpool.Pool) {
|
||||
ctx := context.Background()
|
||||
admin, err := pgxpool.New(ctx, adminDSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Close()
|
||||
const id = `('00000000-0000-4000-8000-' || lpad(to_hex(nextval('claim_test_seq')), 12, '0'))::uuid`
|
||||
for _, q := range []string{
|
||||
"create sequence if not exists claim_test_seq",
|
||||
"grant usage, select on sequence claim_test_seq to " + pgx.Identifier{env("DB_USER", "moonminers")}.Sanitize(),
|
||||
"alter table staking_event alter column id set default " + id,
|
||||
} {
|
||||
if _, err := admin.Exec(ctx, q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
oldID := newStakeID
|
||||
newStakeID = func() string {
|
||||
var s string
|
||||
if err := db.QueryRow(ctx, "select "+id+"::text").Scan(&s); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
newStakeID = oldID
|
||||
admin, err := pgxpool.New(ctx, adminDSN())
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec(ctx, "alter table staking_event alter column id set default gen_random_uuid()"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaimGolden(t *testing.T) {
|
||||
db, _ := testDB(t)
|
||||
seqStakeIDs(t, db)
|
||||
ctx := context.Background()
|
||||
// Live events for the redeployed stakes' amounts and durations; removed at the end.
|
||||
var evs []int
|
||||
for _, e := range []struct {
|
||||
kind, res string
|
||||
mult float64
|
||||
}{{"cycle_multiplier", "all", 1.5}, {"yield_multiplier", "Sollux", 1.25}, {"yield_multiplier", "Azurium", 0.8}} {
|
||||
var id int
|
||||
err := db.QueryRow(ctx, `insert into live_event(name, kind, params, starts_at, ends_at)
|
||||
values ('claim test', $1, $2, now() - interval '1 day', now() + interval '1 day') returning id`,
|
||||
e.kind, map[string]any{"resource": e.res, "multiplier": e.mult}).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evs = append(evs, id)
|
||||
}
|
||||
defer db.Exec(ctx, "delete from live_event where id = any($1)", evs)
|
||||
|
||||
var out strings.Builder
|
||||
run := func(cfgName string) {
|
||||
f := newFx(t, db)
|
||||
claimScenarios(f, &out, cfgName)
|
||||
}
|
||||
run("reach5")
|
||||
withReach(map[int]int{1: 1, 2: 1, 3: 3, 4: 2}, func() { run("mixed") })
|
||||
withReach(map[int]int{1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1}, func() { run("reach1") })
|
||||
|
||||
got := out.String()
|
||||
const path = "testdata/claim_golden.txt"
|
||||
if os.Getenv("MM_CLAIM_GOLDEN_WRITE") == "1" {
|
||||
if err := os.MkdirAll("testdata", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != string(want) {
|
||||
os.WriteFile(os.TempDir()+"/claim_golden.got", []byte(got), 0o644)
|
||||
gl, wl := strings.Split(got, "\n"), strings.Split(string(want), "\n")
|
||||
for i := 0; i < min(len(gl), len(wl)); i++ {
|
||||
if gl[i] != wl[i] {
|
||||
t.Fatalf("differs at line %d (full output in %s/claim_golden.got):\n got: %s\nwant: %s",
|
||||
i+1, os.TempDir(), gl[i], wl[i])
|
||||
}
|
||||
}
|
||||
t.Fatalf("differs in length: got %d lines, want %d", len(gl), len(wl))
|
||||
}
|
||||
}
|
||||
|
||||
// claimScenarios plays every scenario for one config; p prefixes the labels.
|
||||
func claimScenarios(f *fx, out *strings.Builder, p string) {
|
||||
all := ClaimScope{}
|
||||
redeploy := ClaimScope{Redeploy: true}
|
||||
|
||||
// 1. One-well drills overdrilling a well: the third claim empties it and stops the
|
||||
// fourth (ready, later in the list) and a running one; a multi-well drill on the
|
||||
// emptied well; claim-all with redeploy, then the redeployed drills again.
|
||||
u := f.user(p+"-overdrill", nil)
|
||||
w := f.moon(u, p+"-m1", []int{3, 0, 4}, []float64{10, 50, 5})
|
||||
for i, item := range []int{1, 2, 1, 3} {
|
||||
f.stake(u, fmt.Sprintf("%s-s%d", p, i), f.tool(u, fmt.Sprintf("%s-t%d", p, i), item, 0, 0), w[0], 4, 1, true)
|
||||
}
|
||||
f.stake(u, p+"-run", f.tool(u, p+"-t4", 1, 0, 0), w[0], 3, 1, false)
|
||||
f.stake(u, p+"-wide", f.tool(u, p+"-t5", 5, 1, 0), w[2], 7, 5, true)
|
||||
f.stake(u, p+"-az", f.tool(u, p+"-t6", 2, 0, 1), w[1], 9, 1, true)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy")
|
||||
f.finish(u)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy 2")
|
||||
f.finish(u)
|
||||
f.claim(out, u, all, "claim-all")
|
||||
|
||||
// 2. Multi-well drills on a 7-well moon (reach 5 covers part of it) and a 4-well moon,
|
||||
// wells emptied by multi-well claims stopping one-well drills; a moon that finishes.
|
||||
u = f.user(p+"-wide", []string{"market"}, "Ada Voss", "Rook Halvorsen")
|
||||
w7 := f.moon(u, p+"-m7", []int{0, 1, 2, 3, 4, 0, 1}, []float64{3, 8, 0, 12, 2.5, 6, 1})
|
||||
w4 := f.moon(u, p+"-m4", []int{4, 3, 2, 1}, []float64{2, 2, 1, 0.5})
|
||||
n := 0
|
||||
tl := func(item, yl, cl int) string {
|
||||
n++
|
||||
return f.tool(u, fmt.Sprintf("%s-w%d", p, n), item, yl, cl)
|
||||
}
|
||||
f.stake(u, p+"-a1", tl(4, 0, 0), w7[0], 9, 5, true)
|
||||
f.stake(u, p+"-a2", tl(1, 0, 0), w7[3], 5, 1, true)
|
||||
f.stake(u, p+"-a3", tl(5, 2, 1), w7[6], 6, 5, true)
|
||||
f.stake(u, p+"-a4", tl(1, 1, 0), w7[1], 2, 1, true)
|
||||
f.stake(u, p+"-a5", tl(3, 0, 0), w7[2], 4, 3, true)
|
||||
f.stake(u, p+"-a6", tl(1, 0, 0), w7[4], 3, 1, false)
|
||||
f.stake(u, p+"-a7", tl(6, 0, 0), w7[5], 20, 5, false)
|
||||
f.stake(u, p+"-b1", tl(2, 0, 0), w4[0], 1.5, 1, true)
|
||||
f.stake(u, p+"-b2", tl(7, 0, 0), w4[1], 3, 5, true)
|
||||
f.stake(u, p+"-b3", tl(1, 0, 0), w4[3], 1, 1, true)
|
||||
f.stake(u, p+"-b4", tl(4, 0, 0), w4[2], 2, 3, true)
|
||||
m7 := f.moonOf(w7[0])
|
||||
f.claim(out, u, ClaimScope{MoonID: m7}, "claim-moon m7")
|
||||
f.claim(out, u, ClaimScope{MoonID: m7, Redeploy: true}, "claim-moon m7 redeploy")
|
||||
f.claim(out, u, ClaimScope{MoonID: f.moonOf(w4[0]), Wide: true, Redeploy: true}, "claim-wide m4 redeploy")
|
||||
f.claim(out, u, ClaimScope{WellID: w4[0], Redeploy: true}, "claim-well m4.0 redeploy")
|
||||
f.claim(out, u, ClaimScope{WellID: w4[3]}, "claim-well m4.3")
|
||||
f.finish(u)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy")
|
||||
f.finish(u)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy 2")
|
||||
f.finish(u)
|
||||
f.claim(out, u, all, "claim-all")
|
||||
f.claim(out, u, all, "claim-all nothing")
|
||||
|
||||
// 3. A tool with a second unclaimed stake: redeploying it fails the whole claim.
|
||||
u = f.user(p+"-busy", []string{"all"})
|
||||
w = f.moon(u, p+"-mb", []int{0, 1}, []float64{20, 20})
|
||||
t := f.tool(u, p+"-bt", 1, 0, 0)
|
||||
f.stake(u, p+"-bs1", t, w[0], 2, 1, true)
|
||||
f.stake(u, p+"-bs2", t, w[1], 2, 1, false)
|
||||
f.stake(u, p+"-bs3", f.tool(u, p+"-bt2", 2, 0, 0), w[1], 2, 1, true)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy busy")
|
||||
f.claim(out, u, all, "claim-all busy")
|
||||
|
||||
// 4. Many moons and drills with tight wells, seeded: three rounds of claim-all with
|
||||
// redeploy, then one without.
|
||||
u = f.user(p+"-many", nil, "Juno Park")
|
||||
rnd := rand.New(rand.NewSource(7))
|
||||
var moons [][]string
|
||||
for m := 0; m < 25; m++ {
|
||||
k := 1 + rnd.Intn(7)
|
||||
res, sup := make([]int, k), make([]float64, k)
|
||||
for i := range res {
|
||||
res[i] = rnd.Intn(5)
|
||||
sup[i] = float64(rnd.Intn(40)) / 2
|
||||
if rnd.Intn(6) == 0 {
|
||||
sup[i] = 0
|
||||
}
|
||||
}
|
||||
moons = append(moons, f.moon(u, fmt.Sprintf("%s-n%d", p, m), res, sup))
|
||||
}
|
||||
for i := 0; i < 120; i++ {
|
||||
ws := moons[rnd.Intn(len(moons))]
|
||||
item := 1 + rnd.Intn(7)
|
||||
reach := 1
|
||||
if rnd.Intn(2) == 0 {
|
||||
reach = []int{2, 3, 5}[rnd.Intn(3)]
|
||||
}
|
||||
tool := f.tool(u, fmt.Sprintf("%s-x%03d", p, i), item, rnd.Intn(3), rnd.Intn(3))
|
||||
f.stake(u, fmt.Sprintf("%s-y%03d", p, i), tool, ws[rnd.Intn(len(ws))], float64(1+rnd.Intn(16))/2, reach, rnd.Intn(5) != 0)
|
||||
}
|
||||
for r := 0; r < 3; r++ {
|
||||
f.claim(out, u, redeploy, fmt.Sprintf("claim-all redeploy round %d", r))
|
||||
f.finish(u)
|
||||
}
|
||||
f.claim(out, u, all, "claim-all final")
|
||||
}
|
||||
|
||||
func (f *fx) moonOf(well string) string {
|
||||
return f.id("select source_id::text from resource_well where id = $1", well)
|
||||
}
|
||||
|
||||
// TestClaimAllPerf times claim-all for players with n ready drills, and counts its
|
||||
// statements: one whole-moon drill per moon of three wells, or (crowded) n drills on 25
|
||||
// moons, a third of them one-well drills, more than the wells hold.
|
||||
func TestClaimAllPerf(t *testing.T) {
|
||||
db, tr := testDB(t)
|
||||
f := newFx(t, db)
|
||||
type perfRun struct {
|
||||
n int
|
||||
redeploy bool
|
||||
crowded bool
|
||||
}
|
||||
runs := []perfRun{{50, false, false}, {50, true, false}, {200, false, false}, {200, true, false},
|
||||
{900, false, false}, {900, true, false}, {900, false, true}, {900, true, true}}
|
||||
for _, run := range runs {
|
||||
n, moons, supply := run.n, run.n, []float64{40, 30, 3}
|
||||
if run.crowded {
|
||||
moons, supply = 25, []float64{300, 200, 8}
|
||||
}
|
||||
u := f.user(fmt.Sprintf("perf-%d-%v-%v", n, run.redeploy, run.crowded), nil)
|
||||
for m := 0; m < moons; m++ {
|
||||
f.moon(u, fmt.Sprintf("p%d-%d", n, m), []int{m, m + 1, m + 2}, supply)
|
||||
}
|
||||
_, err := db.Exec(f.ctx, `
|
||||
insert into inventory_item(user_id, store_item_id) select $1, 1 + (g % 7) from generate_series(1, $2) g`, u, n)
|
||||
f.must(err)
|
||||
_, err = db.Exec(f.ctx, `
|
||||
with tools as (select id, row_number() over (order by id) r from inventory_item where user_id = $1),
|
||||
moons as (select id, row_number() over (order by name) r from staking_source where user_id = $1)
|
||||
insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
|
||||
select $1, (select id from resource_well where source_id = moons.id order by id offset tools.r % 3 limit 1),
|
||||
tools.id, 10, 5 + tools.r % 4, case when $4 and tools.r % 3 = 0 then 1 else 5 end,
|
||||
$2::timestamptz + tools.r * interval '1 second'
|
||||
from tools join moons on moons.r = (tools.r - 1) % $3 + 1`, u, fxBase, moons, run.crowded)
|
||||
f.must(err)
|
||||
tr.mu.Lock()
|
||||
tr.slow = nil
|
||||
tr.mu.Unlock()
|
||||
start, n0 := time.Now(), tr.n.Load()
|
||||
res, err := claimReady(f.ctx, db, u, ClaimScope{Redeploy: run.redeploy})
|
||||
f.must(err)
|
||||
t.Logf("drills %d redeploy %v crowded %v: claimed %d redeployed %d stopped %d in %v, %d statements",
|
||||
n, run.redeploy, run.crowded, res.Claimed, res.Redeployed, res.Stopped,
|
||||
time.Since(start).Round(time.Millisecond), tr.n.Load()-n0)
|
||||
for _, s := range tr.slow {
|
||||
t.Logf(" slow: %.200s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -29,6 +31,8 @@ type GameConfig struct {
|
||||
StartingTools []int `json:"startingTools"` // store item ids, given free
|
||||
// Moon vendors, cheapest first (rollWells in game.go); the first is the default grade.
|
||||
MoonGrades []MoonGrade `json:"moonGrades"`
|
||||
// Packs of moons bought at once (buyMoons in game.go), smallest first.
|
||||
MoonPacks []MoonPack `json:"moonPacks,omitempty"`
|
||||
// Selling a moon back (resale.go).
|
||||
MoonResale MoonResale `json:"moonResale"`
|
||||
// Selling tools back (resale.go).
|
||||
@@ -67,6 +71,30 @@ type MoonGrade struct {
|
||||
MinValue float64 `json:"minValue,omitempty"`
|
||||
}
|
||||
|
||||
// MoonPack is count moons of one grade bought at once: discount off their price, and each
|
||||
// moon worth at least minValue × its grade's price (re-rolled like a grade's minValue,
|
||||
// whichever floor is higher).
|
||||
type MoonPack struct {
|
||||
Count int `json:"count"`
|
||||
Discount float64 `json:"discount"`
|
||||
MinValue float64 `json:"minValue,omitempty"`
|
||||
}
|
||||
|
||||
// pack is the moon pack of that size (nil: none, or a single moon).
|
||||
func (cfg *GameConfig) pack(count int) *MoonPack {
|
||||
for i := range cfg.MoonPacks {
|
||||
if cfg.MoonPacks[i].Count == count {
|
||||
return &cfg.MoonPacks[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PackPrice is what count moons of the grade cost in this pack.
|
||||
func (p *MoonPack) PackPrice(g *MoonGrade) float64 {
|
||||
return math.Round(float64(p.Count) * g.Price * (1 - p.Discount))
|
||||
}
|
||||
|
||||
// grade is the moon grade with that name, else the first (lowest) one.
|
||||
func (cfg *GameConfig) grade(name string) *MoonGrade {
|
||||
if i := cfg.gradeIndex(name); i >= 0 {
|
||||
@@ -122,6 +150,10 @@ type MoonType struct {
|
||||
|
||||
var game = mustParseGameConfig(gameJSON)
|
||||
|
||||
// resourceNames are the rows of the resource table (sql/data.sql). Wells are stored by
|
||||
// these names, so the config can price no other resource.
|
||||
var resourceNames = []string{"Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance"}
|
||||
|
||||
func mustParseGameConfig(data []byte) GameConfig {
|
||||
var cfg GameConfig
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
@@ -157,6 +189,14 @@ func (cfg *GameConfig) validate() error {
|
||||
if err := cfg.MarketPressure.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, p := range cfg.MoonPacks {
|
||||
if p.Count < 2 || p.Discount < 0 || p.Discount >= 1 || p.MinValue < 0 || p.MinValue > 3 {
|
||||
return fmt.Errorf("moon pack %d: count at least 2, discount from 0 to below 1, minValue 0 to 3", i+1)
|
||||
}
|
||||
if i > 0 && p.Count <= cfg.MoonPacks[i-1].Count {
|
||||
return fmt.Errorf("moon packs: smallest first, no two the same size")
|
||||
}
|
||||
}
|
||||
if err := cfg.MoonResale.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,10 +204,16 @@ func (cfg *GameConfig) validate() error {
|
||||
return fmt.Errorf("equipmentResale.factor must be at least 0 and below 1")
|
||||
}
|
||||
for name, price := range cfg.ResourcePrices {
|
||||
if !slices.Contains(resourceNames, name) {
|
||||
return fmt.Errorf("resourcePrices: %q is not a resource (%s)", name, strings.Join(resourceNames, ", "))
|
||||
}
|
||||
if price < 0 {
|
||||
return fmt.Errorf("%s price is negative", name)
|
||||
}
|
||||
}
|
||||
if len(cfg.StoreItems) == 0 {
|
||||
return fmt.Errorf("no store items")
|
||||
}
|
||||
ids := map[int]bool{}
|
||||
for _, item := range cfg.StoreItems {
|
||||
if item.Price < 0 || item.ClaimAmount < 0 || item.CompletionTimeInSecs < 1 {
|
||||
|
||||
+51
-49
@@ -3,7 +3,7 @@
|
||||
"startingResources": 0,
|
||||
"startingTools": [1],
|
||||
"moonGrades": [
|
||||
{"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 23, "wellSupplyMax": 46, "wellCountWeights": {"1": 40, "2": 60},
|
||||
{"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 28, "wellSupplyMax": 56, "wellCountWeights": {"1": 40, "2": 60},
|
||||
"affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}},
|
||||
{"grade": "C", "price": 12000, "minValue": 1.3, "wellSupplyMin": 432, "wellSupplyMax": 860, "wellCountWeights": {"2": 50, "3": 50},
|
||||
"affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}},
|
||||
@@ -14,6 +14,8 @@
|
||||
{"grade": "S", "price": 24000000, "wellSupplyMin": 337840, "wellSupplyMax": 829840, "wellCountWeights": {"4": 50, "5": 50},
|
||||
"affinities": {"Sollux": 0.4, "Shadowstone": 0.6, "Azurium": 1, "Novafor": 2, "Nebulance": 3.5}}
|
||||
],
|
||||
"moonPacks": [{"count": 5, "discount": 0.05, "minValue": 1.4}, {"count": 10, "discount": 0.1, "minValue": 1.5},
|
||||
{"count": 20, "discount": 0.15, "minValue": 1.6}],
|
||||
"moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600},
|
||||
"equipmentResale": {"factor": 0.5},
|
||||
"marketVolatility": 0.25,
|
||||
@@ -33,20 +35,20 @@
|
||||
"image": "shovel.png",
|
||||
"price": 1200,
|
||||
"claimAmount": 8,
|
||||
"completionTimeInSecs": 3,
|
||||
"completionTimeInSecs": 3, "reach": 5,
|
||||
"tracks": {
|
||||
"yield": {"name": "Twin rotors", "levels": [
|
||||
{"value": 10, "price": 280},
|
||||
{"value": 12, "price": 280},
|
||||
{"value": 14, "price": 290, "parts": {"Dusk Alloy": 1}},
|
||||
{"value": 16, "price": 280, "parts": {"Dusk Alloy": 2}}
|
||||
{"value": 14, "price": 290},
|
||||
{"value": 16, "price": 280}
|
||||
]},
|
||||
"cycle": {"name": "Flight computer", "levels": [
|
||||
{"value": 2.6, "price": 180},
|
||||
{"value": 2.2, "price": 240},
|
||||
{"value": 1.9, "price": 250, "parts": {"Dusk Alloy": 1}},
|
||||
{"value": 1.7, "price": 210, "parts": {"Dusk Alloy": 1}},
|
||||
{"value": 1.5, "price": 270, "parts": {"Dusk Alloy": 2}}
|
||||
{"value": 1.9, "price": 250},
|
||||
{"value": 1.7, "price": 210},
|
||||
{"value": 1.5, "price": 270}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -57,21 +59,21 @@
|
||||
"image": "pickaxe.png",
|
||||
"price": 8000,
|
||||
"claimAmount": 120,
|
||||
"completionTimeInSecs": 6,
|
||||
"completionTimeInSecs": 6, "reach": 5,
|
||||
"tracks": {
|
||||
"yield": {"name": "Wider chisel", "levels": [
|
||||
{"value": 144, "price": 1520},
|
||||
{"value": 168, "price": 1520},
|
||||
{"value": 192, "price": 1520, "parts": {"Dusk Alloy": 2}},
|
||||
{"value": 216, "price": 1520, "parts": {"Cryo Cell": 1}},
|
||||
{"value": 240, "price": 1520, "parts": {"Plasma Weave": 1}}
|
||||
{"value": 192, "price": 1520},
|
||||
{"value": 216, "price": 1520},
|
||||
{"value": 240, "price": 1520}
|
||||
]},
|
||||
"cycle": {"name": "Pneumatic hammer", "levels": [
|
||||
{"value": 5.2, "price": 1160},
|
||||
{"value": 4.5, "price": 1360},
|
||||
{"value": 3.9, "price": 1560, "parts": {"Dusk Alloy": 2}},
|
||||
{"value": 3.4, "price": 1720, "parts": {"Cryo Cell": 1}},
|
||||
{"value": 3, "price": 1780, "parts": {"Plasma Weave": 1}}
|
||||
{"value": 3.9, "price": 1560},
|
||||
{"value": 3.4, "price": 1720},
|
||||
{"value": 3, "price": 1780}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -82,21 +84,21 @@
|
||||
"image": "lunarlite.jpg",
|
||||
"price": 40200,
|
||||
"claimAmount": 900,
|
||||
"completionTimeInSecs": 8,
|
||||
"completionTimeInSecs": 8, "reach": 5,
|
||||
"tracks": {
|
||||
"yield": {"name": "Wide-bore bit", "levels": [
|
||||
{"value": 1060, "price": 6900},
|
||||
{"value": 1270, "price": 8700},
|
||||
{"value": 1430, "price": 6900, "parts": {"Cryo Cell": 1}},
|
||||
{"value": 1640, "price": 8700, "parts": {"Plasma Weave": 2}},
|
||||
{"value": 1800, "price": 6900, "parts": {"Plasma Weave": 3}}
|
||||
{"value": 1430, "price": 6900},
|
||||
{"value": 1640, "price": 8700},
|
||||
{"value": 1800, "price": 6900}
|
||||
]},
|
||||
"cycle": {"name": "Spindle motor", "levels": [
|
||||
{"value": 7, "price": 5400},
|
||||
{"value": 6, "price": 7200},
|
||||
{"value": 5.2, "price": 7800, "parts": {"Cryo Cell": 1}},
|
||||
{"value": 4.6, "price": 7800, "parts": {"Plasma Weave": 2}},
|
||||
{"value": 4, "price": 9900, "parts": {"Plasma Weave": 3}}
|
||||
{"value": 5.2, "price": 7800},
|
||||
{"value": 4.6, "price": 7800},
|
||||
{"value": 4, "price": 9900}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -108,21 +110,21 @@
|
||||
"price": 645000,
|
||||
"claimAmount": 20000,
|
||||
"completionTimeInSecs": 10,
|
||||
"reach": 2,
|
||||
"reach": 5,
|
||||
"tracks": {
|
||||
"yield": {"name": "Wider beam", "levels": [
|
||||
{"value": 23700, "price": 115000},
|
||||
{"value": 28200, "price": 135000},
|
||||
{"value": 31800, "price": 115000, "parts": {"Cryo Cell": 2}},
|
||||
{"value": 36300, "price": 135000, "parts": {"Plasma Weave": 3}},
|
||||
{"value": 40000, "price": 115000, "parts": {"Void Core": 1}}
|
||||
{"value": 31800, "price": 115000},
|
||||
{"value": 36300, "price": 135000},
|
||||
{"value": 40000, "price": 115000}
|
||||
]},
|
||||
"cycle": {"name": "Laser capacitors", "levels": [
|
||||
{"value": 8.7, "price": 90000},
|
||||
{"value": 7.5, "price": 115000},
|
||||
{"value": 6.5, "price": 125000, "parts": {"Cryo Cell": 2}},
|
||||
{"value": 5.7, "price": 130000, "parts": {"Plasma Weave": 3}},
|
||||
{"value": 5, "price": 150000, "parts": {"Void Core": 1}}
|
||||
{"value": 6.5, "price": 125000},
|
||||
{"value": 5.7, "price": 130000},
|
||||
{"value": 5, "price": 150000}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -134,21 +136,21 @@
|
||||
"price": 9440000,
|
||||
"claimAmount": 384000,
|
||||
"completionTimeInSecs": 12,
|
||||
"reach": 3,
|
||||
"reach": 5,
|
||||
"tracks": {
|
||||
"yield": {"name": "Fine sieves", "levels": [
|
||||
{"value": 452000, "price": 1600000},
|
||||
{"value": 542000, "price": 2080000},
|
||||
{"value": 610000, "price": 1600000, "parts": {"Plasma Weave": 3}},
|
||||
{"value": 700000, "price": 2080000, "parts": {"Void Core": 1}},
|
||||
{"value": 768000, "price": 1600000, "parts": {"Stellar Lattice": 1}}
|
||||
{"value": 610000, "price": 1600000},
|
||||
{"value": 700000, "price": 2080000},
|
||||
{"value": 768000, "price": 1600000}
|
||||
]},
|
||||
"cycle": {"name": "Auger gearbox", "levels": [
|
||||
{"value": 10.4, "price": 1360000},
|
||||
{"value": 9, "price": 1600000},
|
||||
{"value": 7.8, "price": 1840000, "parts": {"Plasma Weave": 3}},
|
||||
{"value": 6.8, "price": 2000000, "parts": {"Void Core": 1}},
|
||||
{"value": 6, "price": 2080000, "parts": {"Stellar Lattice": 1}}
|
||||
{"value": 7.8, "price": 1840000},
|
||||
{"value": 6.8, "price": 2000000},
|
||||
{"value": 6, "price": 2080000}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -165,16 +167,16 @@
|
||||
"yield": {"name": "Larger plates", "levels": [
|
||||
{"value": 1050000, "price": 3120000},
|
||||
{"value": 1220000, "price": 3120000},
|
||||
{"value": 1420000, "price": 3600000, "parts": {"Void Core": 1}},
|
||||
{"value": 1590000, "price": 3120000, "parts": {"Stellar Lattice": 1}},
|
||||
{"value": 1760000, "price": 3120000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
|
||||
{"value": 1420000, "price": 3600000},
|
||||
{"value": 1590000, "price": 3120000},
|
||||
{"value": 1760000, "price": 3120000}
|
||||
]},
|
||||
"cycle": {"name": "Charge coils", "levels": [
|
||||
{"value": 12.2, "price": 2400000},
|
||||
{"value": 10.5, "price": 2960000},
|
||||
{"value": 9.1, "price": 3280000, "parts": {"Void Core": 1}},
|
||||
{"value": 8, "price": 3440000, "parts": {"Stellar Lattice": 1}},
|
||||
{"value": 7, "price": 4000000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
|
||||
{"value": 9.1, "price": 3280000},
|
||||
{"value": 8, "price": 3440000},
|
||||
{"value": 7, "price": 4000000}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -191,16 +193,16 @@
|
||||
"yield": {"name": "Core barrel", "levels": [
|
||||
{"value": 1910000, "price": 4480000},
|
||||
{"value": 2250000, "price": 5040000},
|
||||
{"value": 2550000, "price": 4480000, "parts": {"Void Core": 2}},
|
||||
{"value": 2890000, "price": 5040000, "parts": {"Stellar Lattice": 2}},
|
||||
{"value": 3200000, "price": 4480000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
|
||||
{"value": 2550000, "price": 4480000},
|
||||
{"value": 2890000, "price": 5040000},
|
||||
{"value": 3200000, "price": 4480000}
|
||||
]},
|
||||
"cycle": {"name": "Drill string", "levels": [
|
||||
{"value": 14, "price": 3360000},
|
||||
{"value": 12, "price": 4480000},
|
||||
{"value": 10.4, "price": 4800000, "parts": {"Void Core": 2}},
|
||||
{"value": 9.1, "price": 5200000, "parts": {"Stellar Lattice": 2}},
|
||||
{"value": 8, "price": 5680000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
|
||||
{"value": 10.4, "price": 4800000},
|
||||
{"value": 9.1, "price": 5200000},
|
||||
{"value": 8, "price": 5680000}
|
||||
]}
|
||||
}
|
||||
}
|
||||
@@ -269,7 +271,7 @@
|
||||
"bulkLog": 1.0,
|
||||
"unlockGrade": "C",
|
||||
"items": [
|
||||
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 24, "seconds": 1,
|
||||
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 28, "seconds": 1,
|
||||
"inputs": {"Sollux": 2, "Shadowstone": 2},
|
||||
"description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."},
|
||||
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 97, "seconds": 2,
|
||||
@@ -278,7 +280,7 @@
|
||||
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 134, "seconds": 2,
|
||||
"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, "unlockGrade": "B", "color": "#c4b5fd", "price": 450, "seconds": 3,
|
||||
{"name": "Void Core", "tier": 2, "unlockGrade": "B", "color": "#c4b5fd", "price": 460, "seconds": 3,
|
||||
"inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5},
|
||||
"description": "A cold, dense power core that drives the heaviest rigs for weeks."},
|
||||
{"name": "Stellar Lattice", "tier": 2, "unlockGrade": "B", "color": "#fde047", "price": 470, "seconds": 3,
|
||||
|
||||
+54
-17
@@ -12,6 +12,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
@@ -295,8 +296,9 @@ func watchOverride(ctx context.Context, db *pgxpool.Pool) {
|
||||
}
|
||||
|
||||
// saveOverride records a new override (by the given admin), re-syncs the store items and
|
||||
// puts it in effect.
|
||||
func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note, userID string) (int64, error) {
|
||||
// puts it in effect. With since >= 0 it refuses when the newest saved version is not since
|
||||
// (0: none), so a save from a page loaded before another save doesn't undo that save.
|
||||
func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note, userID string, since int64) (int64, error) {
|
||||
c, err := applyOverride(ov)
|
||||
if err != nil {
|
||||
return 0, gameErr("%s", err.Error())
|
||||
@@ -307,6 +309,19 @@ func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note
|
||||
}
|
||||
var id int64
|
||||
err = inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
if since >= 0 {
|
||||
// The lock makes two saves from the same version take turns.
|
||||
if _, err := tx.Exec(ctx, "lock table config_override in share row exclusive mode"); err != nil {
|
||||
return err
|
||||
}
|
||||
var latest int64
|
||||
if err := tx.QueryRow(ctx, "select coalesce(max(id), 0) from config_override").Scan(&latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if latest != since {
|
||||
return gameErr("Version #%d was saved after this page loaded. Reload the page and make your changes again.", latest)
|
||||
}
|
||||
}
|
||||
err := tx.QueryRow(ctx,
|
||||
`insert into config_override(data, note, created_by)
|
||||
values ($1, $2, (select id from users where id::text = $3)) returning id`,
|
||||
@@ -490,10 +505,6 @@ func (s *server) economyRoutes(mux *http.ServeMux) {
|
||||
}
|
||||
|
||||
func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.isAdmin(s.currentUser(r)) {
|
||||
s.forbidden(w, "economy", &EconomyPage{})
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
// Pick up a save made by another instance before showing the editor.
|
||||
if err := loadOverride(ctx, s.db); err != nil {
|
||||
@@ -505,18 +516,33 @@ func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
// The newest saved version (not always the one in effect: an invalid one is skipped);
|
||||
// a save from this page is refused once there is a newer one.
|
||||
var latest int64
|
||||
if len(history) > 0 {
|
||||
latest = history[0].ID
|
||||
}
|
||||
base := baseConfig()
|
||||
c := cfg()
|
||||
quotes := marketQuotes(c, time.Now())
|
||||
s.render(w, "economy", &EconomyPage{
|
||||
Allowed: true,
|
||||
Data: map[string]any{
|
||||
data := map[string]any{
|
||||
"saved": configDoc(c),
|
||||
"base": configDoc(&base),
|
||||
"version": overrideVersion.Load(),
|
||||
"latest": latest,
|
||||
"market": quotes,
|
||||
"colors": resourceColors,
|
||||
},
|
||||
}
|
||||
// Dev only: ?draft=<JSON merge patch> opens the editor with that draft (screenshots).
|
||||
if raw := r.FormValue("draft"); s.dev && raw != "" {
|
||||
var patch any
|
||||
if err := json.Unmarshal([]byte(raw), &patch); err == nil {
|
||||
data["draft"] = mergePatch(configDoc(c), patch)
|
||||
}
|
||||
}
|
||||
s.render(w, "economy", &EconomyPage{
|
||||
Allowed: true,
|
||||
Data: data,
|
||||
Quotes: quotes,
|
||||
History: history,
|
||||
Version: overrideVersion.Load(),
|
||||
@@ -529,11 +555,6 @@ func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
|
||||
// goes back to game.json. Save, revert and reset answer JSON: {"version", "message"} or
|
||||
// {"errors": [...]}.
|
||||
func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
||||
userID := s.currentUser(r)
|
||||
if !s.isAdmin(userID) {
|
||||
http.Error(w, "Missing permission", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
reply := func(status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -542,6 +563,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var ov ConfigOverride
|
||||
var note string
|
||||
since := int64(-1) // save: the version the page loaded with
|
||||
switch r.PathValue("action") {
|
||||
case "analyze":
|
||||
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
|
||||
@@ -549,7 +571,18 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "economy-analysis", analyze(&draft, []string{err.Error()}, 0))
|
||||
return
|
||||
}
|
||||
s.render(w, "economy-analysis", analyze(&draft, nil, diffConfig(cfg(), &draft).ChangeCount()))
|
||||
n := diffConfig(cfg(), &draft).ChangeCount()
|
||||
a := analyze(&draft, nil, n)
|
||||
a.Progress = progression(cfg(), &draft, n == 0)
|
||||
s.render(w, "economy-analysis", a)
|
||||
return
|
||||
case "sim":
|
||||
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, `<p class="eco-bad">%s</p>`, template.HTMLEscapeString(err.Error()))
|
||||
return
|
||||
}
|
||||
s.render(w, "economy-sim", simTimeline(cfg(), &draft, diffConfig(cfg(), &draft).ChangeCount() == 0))
|
||||
return
|
||||
case "save":
|
||||
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
|
||||
@@ -557,6 +590,10 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
||||
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
|
||||
return
|
||||
}
|
||||
if since, err = strconv.ParseInt(r.PostFormValue("since"), 10, 64); err != nil || since < 0 {
|
||||
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"Reload the page: the save didn't say which version it started from"}})
|
||||
return
|
||||
}
|
||||
if diffConfig(cfg(), &draft).ChangeCount() == 0 {
|
||||
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"No changes to save"}})
|
||||
return
|
||||
@@ -584,7 +621,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id, err := saveOverride(ctx, s.db, ov, note, userID)
|
||||
id, err := saveOverride(ctx, s.db, ov, note, "", since)
|
||||
if err != nil {
|
||||
if isGameError(err) {
|
||||
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
|
||||
|
||||
+748
-72
File diff suppressed because it is too large.
Load diff
+31
-4
@@ -34,6 +34,8 @@ const (
|
||||
gsClickBurst = 4.0 // clicks a player can bank for a burst
|
||||
gsSellEvery = 30 // seconds between sell rounds (one click per resource)
|
||||
gsClaimGap = 3 // seconds a player waits at least between two claims
|
||||
gsSampleEvery = 30 // seconds between timeline samples
|
||||
gsIdleShow = 5 // shortest stretch with nothing to click the timeline shows
|
||||
)
|
||||
|
||||
type gsTool struct {
|
||||
@@ -116,6 +118,23 @@ type GSResult struct {
|
||||
BuyGapAt SimTime
|
||||
BuyGapPreS SimTime // the same, before the first moon of the top grade
|
||||
IdleAt SimTime
|
||||
// For the timeline chart (progression.go):
|
||||
Samples []GSSample // every gsSampleEvery seconds
|
||||
Buys []GSEvent // copies, upgrades and advisors (moons: FirstGrade)
|
||||
Idles [][2]int // stretches of gsIdleShow seconds or more with nothing to click, [from, to)
|
||||
}
|
||||
|
||||
// GSSample is the player's credits and total earnings at a time (seconds).
|
||||
type GSSample struct {
|
||||
At int
|
||||
Credits, Earned float64
|
||||
}
|
||||
|
||||
// GSEvent is a purchase at a time (seconds).
|
||||
type GSEvent struct {
|
||||
At int
|
||||
Kind, Name string
|
||||
Cost float64
|
||||
}
|
||||
|
||||
// GradeStat is the value of a grade's moons at base prices.
|
||||
@@ -222,7 +241,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
||||
var advisors []string
|
||||
seats := 1
|
||||
refinery := c.Refinery.UnlockGrade == ""
|
||||
grade := 0 // of the last moon bought
|
||||
grade := 0 // the dearest moon bought: purchases are valued on it, and its price is kept
|
||||
pressure := map[string]float64{}
|
||||
stock := map[string]float64{}
|
||||
var resources []string
|
||||
@@ -354,6 +373,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
||||
if gap := SimTime(t - lastClick); gap > res.Idle {
|
||||
res.Idle, res.IdleAt = gap, SimTime(lastClick)
|
||||
}
|
||||
if t-lastClick >= gsIdleShow {
|
||||
res.Idles = append(res.Idles, [2]int{lastClick, t})
|
||||
}
|
||||
lastClick = t
|
||||
return true
|
||||
}
|
||||
@@ -372,7 +394,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
||||
freeOre += m.free
|
||||
open = append(open, len(moons))
|
||||
moons = append(moons, m)
|
||||
grade = g
|
||||
grade = max(grade, g)
|
||||
if _, ok := res.FirstGrade[gr.Grade]; !ok {
|
||||
res.FirstGrade[gr.Grade] = SimTime(t)
|
||||
res.GradeClicks[gr.Grade] = clicks
|
||||
@@ -507,6 +529,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
||||
lastSell = t
|
||||
}
|
||||
}
|
||||
if t%gsSampleEvery == 0 {
|
||||
res.Samples = append(res.Samples, GSSample{t, credits, earned})
|
||||
}
|
||||
report := next < len(gsReportAt) && t >= gsReportAt[next]*60
|
||||
if !wantBuy && !report && freeOre >= need() {
|
||||
continue
|
||||
@@ -558,10 +583,11 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
||||
wantBuy = false
|
||||
for range 50 {
|
||||
b := best("", pl.All)
|
||||
// Keep enough for the next moon, unless the drills have plenty of ore.
|
||||
// Keep enough for the next moon, unless the drills have plenty of ore: the dearest
|
||||
// grade bought (a grade picked from credits would fall with every purchase).
|
||||
reserve := 0.0
|
||||
if freeOre < 20*need() {
|
||||
reserve = c.MoonGrades[nextGrade(income)].Price
|
||||
reserve = c.MoonGrades[grade].Price
|
||||
}
|
||||
if b.Kind == "" || credits-b.Cost < reserve {
|
||||
break
|
||||
@@ -579,6 +605,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
||||
}
|
||||
credits -= b.Cost
|
||||
res.Bought[b.Kind]++
|
||||
res.Buys = append(res.Buys, GSEvent{t, b.Kind, b.Name, b.Cost})
|
||||
res.Spent[b.Kind] += b.Cost
|
||||
res.GainMin[b.Kind] += b.Gain / 60
|
||||
res.GainClick[b.Kind] += b.Click
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package main
|
||||
|
||||
// The Leaderboard section: the richest miners by credit balance, with the player's own
|
||||
// place below the top when they're not in it. Banned players don't rank. Template:
|
||||
// leaderboard-section in app.html.
|
||||
// place below the top when they're not in it. Banned players don't rank.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
+13
-18
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@@ -27,9 +28,10 @@ func logEvent(ctx context.Context, q querier, userID, kind string, data map[stri
|
||||
return err
|
||||
}
|
||||
|
||||
// Credits spent are logged as "price" on these kinds, earned as "credits" on sell.
|
||||
// Credits spent are logged as "price" on spendKinds, earned as "credits" on earnKinds.
|
||||
// buy_attachment: attachments were sold until migration 020 (kept for older events).
|
||||
const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'buy_attachment', 'hire_advisor', 'buy_seat'"
|
||||
const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'upgrade_batch', 'buy_attachment', 'hire_advisor', 'buy_seat'"
|
||||
const earnKinds = "'sell', 'sell_moon', 'sell_tool'"
|
||||
|
||||
// ------------------------------------------------------------------ dashboard
|
||||
|
||||
@@ -94,7 +96,7 @@ func loadDashboard(ctx context.Context, q querier) (*Dashboard, error) {
|
||||
where created_at >= day and created_at < day + 1 and kind not in ('session', 'new_player')
|
||||
and kind not like 'admin\_%'),
|
||||
(select coalesce(sum((data->>'credits')::float8), 0) from event_log
|
||||
where created_at >= day and created_at < day + 1 and kind = 'sell'),
|
||||
where created_at >= day and created_at < day + 1 and kind in (`+earnKinds+`)),
|
||||
(select coalesce(sum((data->>'price')::float8), 0) from event_log
|
||||
where created_at >= day and created_at < day + 1 and kind in (`+spendKinds+`))
|
||||
from days order by day`)
|
||||
@@ -125,20 +127,21 @@ func loadDashboard(ctx context.Context, q querier) (*Dashboard, error) {
|
||||
d.ActiveToday = d.Days[n-1].Active
|
||||
}
|
||||
|
||||
// Dn retention: of the players who joined in the last 30 days at least n days ago,
|
||||
// the share that did something n days after the day they joined.
|
||||
// Dn retention: of the players who joined in the last 30 days whose day n is over
|
||||
// (joined at least n+1 days ago), the share that did something n days after the day
|
||||
// they joined.
|
||||
err = q.QueryRow(ctx, `
|
||||
with cohort as (
|
||||
select id, created_at::date as day from users
|
||||
where created_at >= current_date - 30
|
||||
)
|
||||
select
|
||||
count(*) filter (where day <= current_date - 1),
|
||||
count(*) filter (where day <= current_date - 1 and exists (
|
||||
count(*) filter (where day <= current_date - 2),
|
||||
count(*) filter (where day <= current_date - 2 and exists (
|
||||
select 1 from event_log where user_id = cohort.id
|
||||
and created_at >= day + 1 and created_at < day + 2 and kind not like 'admin\_%')),
|
||||
count(*) filter (where day <= current_date - 7),
|
||||
count(*) filter (where day <= current_date - 7 and exists (
|
||||
count(*) filter (where day <= current_date - 8),
|
||||
count(*) filter (where day <= current_date - 8 and exists (
|
||||
select 1 from event_log where user_id = cohort.id
|
||||
and created_at >= day + 7 and created_at < day + 8 and kind not like 'admin\_%'))
|
||||
from cohort`).Scan(&d.D1.Cohort, &d.D1.Returned, &d.D7.Cohort, &d.D7.Returned)
|
||||
@@ -223,10 +226,6 @@ func (s *server) liveOps(r *http.Request) (*LiveOps, error) {
|
||||
}
|
||||
|
||||
func (s *server) handleLiveOps(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.isAdmin(s.currentUser(r)) {
|
||||
s.forbidden(w, "liveops", &LiveOps{})
|
||||
return
|
||||
}
|
||||
lo, err := s.liveOps(r)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
@@ -257,10 +256,6 @@ func (s *server) handleLiveOps(w http.ResponseWriter, r *http.Request) {
|
||||
// handleLiveOpsAction saves, toggles or deletes an event or announcement and re-renders
|
||||
// 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")
|
||||
@@ -370,7 +365,7 @@ func parseAnnouncement(r *http.Request) (Announcement, string) {
|
||||
switch {
|
||||
case a.Message == "":
|
||||
msg = "Write a message"
|
||||
case len(a.Message) > 200:
|
||||
case utf8.RuneCountInString(a.Message) > 200: // as the form's maxlength counts
|
||||
msg = "Keep the message under 200 characters"
|
||||
case !slices.Contains(announcementLevels, a.Level):
|
||||
msg = "Pick a level"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
// Moon Miners: a server-rendered browser mining game.
|
||||
// Moon Miners: the game server (JSON API for the Defold client) and its admin pages.
|
||||
//
|
||||
// moonminers [serve] run the web server (default)
|
||||
// moonminers [serve] run the server (default)
|
||||
// moonminers migrate create roles/schema, apply migrations, load the game config
|
||||
// moonminers load-config sync the store items in the database with the game config
|
||||
// moonminers sim [file] print the economy analysis for the game config (or a draft file)
|
||||
|
||||
+93
-100
@@ -15,6 +15,8 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -73,9 +75,84 @@ func floorDiv(a, b int64) int64 {
|
||||
return q
|
||||
}
|
||||
|
||||
// marketRateAt is the market price of a unit at a tick, before live events.
|
||||
// marketRateAt is the market price of a unit at a tick, before live events. Ticks in the
|
||||
// cached 24h window (marketSeries) are read from it; the current tick fills it.
|
||||
func marketRateAt(name string, tick int64) float64 {
|
||||
return cfg().unitPrice(name) * (1 + cfg().MarketVolatility*marketDrift(name, tick))
|
||||
c := cfg()
|
||||
if mc := marketCached.Load(); mc != nil && mc.cfg == c && tick <= mc.tick && tick > mc.tick-marketPoints {
|
||||
return mc.get(name).series[marketPoints-1-int(mc.tick-tick)]
|
||||
}
|
||||
if d := tick - marketTick(time.Now()); d >= -1 && d <= 1 {
|
||||
return marketSeriesAt(name, tick).rate
|
||||
}
|
||||
return rateAt(c, name, tick)
|
||||
}
|
||||
|
||||
func rateAt(c *GameConfig, name string, tick int64) float64 {
|
||||
return c.unitPrice(name) * (1 + c.MarketVolatility*marketDrift(name, tick))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ shared 24h series
|
||||
|
||||
// marketSeries is an item's market rate over the 24h up to a tick (marketPoints, oldest
|
||||
// first) and what the market shows of it. It's shared by every player: read it, never
|
||||
// modify it. Live price events aren't in it (they multiply the quote per request), so
|
||||
// they never make it stale.
|
||||
type marketSeries struct {
|
||||
series []float64
|
||||
rate, open, high, low, change float64
|
||||
}
|
||||
|
||||
// marketTickCache holds the series of one tick under one config snapshot (cfg() is a new
|
||||
// pointer whenever the economy editor or another instance changes the config).
|
||||
type marketTickCache struct {
|
||||
cfg *GameConfig
|
||||
tick int64
|
||||
items sync.Map // name → *marketSeries
|
||||
}
|
||||
|
||||
var marketCached atomic.Pointer[marketTickCache]
|
||||
|
||||
func (mc *marketTickCache) get(name string) *marketSeries {
|
||||
if v, ok := mc.items.Load(name); ok {
|
||||
return v.(*marketSeries)
|
||||
}
|
||||
v, _ := mc.items.LoadOrStore(name, newMarketSeries(mc.cfg, name, mc.tick))
|
||||
return v.(*marketSeries)
|
||||
}
|
||||
|
||||
// marketSeriesAt is an item's series up to tick, cached for the newest tick asked for.
|
||||
func marketSeriesAt(name string, tick int64) *marketSeries {
|
||||
c := cfg()
|
||||
mc := marketCached.Load()
|
||||
if mc == nil || mc.cfg != c || mc.tick < tick {
|
||||
next := &marketTickCache{cfg: c, tick: tick}
|
||||
if marketCached.CompareAndSwap(mc, next) {
|
||||
mc = next
|
||||
} else {
|
||||
mc = marketCached.Load()
|
||||
}
|
||||
}
|
||||
if mc == nil || mc.cfg != c || mc.tick != tick {
|
||||
return newMarketSeries(c, name, tick) // an older tick, or a racing config change
|
||||
}
|
||||
return mc.get(name)
|
||||
}
|
||||
|
||||
func newMarketSeries(c *GameConfig, name string, tick int64) *marketSeries {
|
||||
m := &marketSeries{series: make([]float64, marketPoints)}
|
||||
for i := range m.series {
|
||||
m.series[i] = rateAt(c, name, tick-int64(marketPoints-1-i))
|
||||
}
|
||||
m.rate, m.open = m.series[marketPoints-1], m.series[0]
|
||||
m.high, m.low = m.series[0], m.series[0]
|
||||
for _, p := range m.series {
|
||||
m.high, m.low = max(m.high, p), min(m.low, p)
|
||||
}
|
||||
if m.open > 0 {
|
||||
m.change = (m.rate/m.open - 1) * 100
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// marketRate is the market price of a unit at time t, before live events.
|
||||
@@ -189,13 +266,14 @@ func loadPressure(ctx context.Context, q querier, s *State) error {
|
||||
// PriceBoosts are the active price_multiplier events: by resource name, and "all".
|
||||
type PriceBoosts map[string]float64
|
||||
|
||||
// For is the combined multiplier for a resource (same rule as eventMultiplier).
|
||||
// For is the combined multiplier for a resource (same rule as eventMultiplier); For("all")
|
||||
// is the "all" events alone.
|
||||
func (b PriceBoosts) For(name string) float64 {
|
||||
m := 1.0
|
||||
if v, ok := b["all"]; ok {
|
||||
m *= v
|
||||
}
|
||||
if v, ok := b[name]; ok {
|
||||
if v, ok := b[name]; ok && name != "all" {
|
||||
m *= v
|
||||
}
|
||||
return m
|
||||
@@ -249,10 +327,9 @@ type Quote struct {
|
||||
High float64 // 24h high/low of the market rate
|
||||
Low float64
|
||||
Value float64 // Balance × Price (at the quote)
|
||||
Spark string // SVG path, viewBox 0 0 100 32
|
||||
URL string
|
||||
Selected bool
|
||||
series []float64
|
||||
series []float64 // the shared 24h market rate (marketSeries): read only
|
||||
}
|
||||
|
||||
func (q Quote) Up() bool { return q.Change >= 0 }
|
||||
@@ -267,7 +344,7 @@ func (q Quote) PressureText() string {
|
||||
}
|
||||
|
||||
// Unpressed is what one unit pays the player before selling pressure (quote × advisors);
|
||||
// the sell ticket (market.js) applies the pressure curve to its amount.
|
||||
// the sell ticket applies the pressure curve to its amount.
|
||||
func (q Quote) Unpressed() float64 { return q.Price * q.Advisor }
|
||||
|
||||
// Unit is what one unit pays the player now (a tiny sale).
|
||||
@@ -279,7 +356,6 @@ func (q Quote) SellAll() float64 {
|
||||
}
|
||||
|
||||
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) }
|
||||
@@ -311,34 +387,12 @@ func marketSymbol(name string) string {
|
||||
return strings.ToUpper(name)
|
||||
}
|
||||
|
||||
// MarketChart is the selected quote's 24h chart, viewBox 0 0 ChartW ChartH.
|
||||
// MarketChart is the selected quote's 24h chart: its points (the quote's series) start at
|
||||
// Start, unix ms, Step ms apart.
|
||||
type MarketChart struct {
|
||||
Line, Area string
|
||||
Prices string // comma-separated, for the crosshair (market.js)
|
||||
Start, Step int64 // unix ms of the first point, ms between points
|
||||
HighY, LowY float64
|
||||
Grid []GridLine
|
||||
Times []TimeLabel
|
||||
Start, Step int64
|
||||
}
|
||||
|
||||
type GridLine struct {
|
||||
Y float64 // in viewBox units
|
||||
Top float64 // the same, as a percent of the height
|
||||
Label string
|
||||
}
|
||||
|
||||
type TimeLabel struct {
|
||||
X float64 // percent
|
||||
Label string
|
||||
}
|
||||
|
||||
const (
|
||||
chartW = 600
|
||||
chartH = 220
|
||||
sparkW = 100
|
||||
sparkH = 32
|
||||
)
|
||||
|
||||
// marketPage builds the market from the rows the market section lists (resources and
|
||||
// refined items).
|
||||
func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
||||
@@ -347,23 +401,13 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
||||
for _, row := range rows {
|
||||
q := Quote{MarketRow: row, Boost: s.PriceBoosts.For(row.Resource), Advisor: s.Bonus().Sell,
|
||||
URL: View{Section: "market", Moon: v.Moon, Pick: row.Resource}.URL()}
|
||||
q.series = make([]float64, marketPoints)
|
||||
for i := range q.series {
|
||||
q.series[i] = marketRateAt(row.Resource, now-int64(marketPoints-1-i))
|
||||
}
|
||||
q.Rate, q.Open = q.series[marketPoints-1], q.series[0]
|
||||
q.High, q.Low = q.series[0], q.series[0]
|
||||
for _, p := range q.series {
|
||||
q.High, q.Low = max(q.High, p), min(q.Low, p)
|
||||
}
|
||||
if q.Open > 0 {
|
||||
q.Change = (q.Rate/q.Open - 1) * 100
|
||||
}
|
||||
m := marketSeriesAt(row.Resource, now)
|
||||
q.series = m.series
|
||||
q.Rate, q.Open, q.High, q.Low, q.Change = m.rate, m.open, m.high, m.low, m.change
|
||||
q.Pressure = s.Pressure[row.Resource]
|
||||
q.Factor = cfg().MarketPressure.Factor(q.Pressure)
|
||||
q.Price = q.Rate * q.Boost
|
||||
q.Value = q.Balance * q.Price
|
||||
q.Spark, _ = chartPaths(q.series, 3, sparkW, sparkH, q.Low, q.High)
|
||||
mp.Quotes = append(mp.Quotes, q)
|
||||
}
|
||||
for i := range mp.Quotes {
|
||||
@@ -387,62 +431,11 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
||||
}
|
||||
if q := mp.Selected; q != nil {
|
||||
q.Selected = true
|
||||
mp.Chart = s.marketChart(q, now)
|
||||
mp.Chart = marketChart(now)
|
||||
}
|
||||
return mp
|
||||
}
|
||||
|
||||
func (s *State) marketChart(q *Quote, now int64) MarketChart {
|
||||
// Pad the range so the line doesn't touch the edges.
|
||||
pad := max((q.High-q.Low)*0.12, q.Rate*0.005)
|
||||
lo, hi := q.Low-pad, q.High+pad
|
||||
c := MarketChart{Step: marketStep.Milliseconds(),
|
||||
Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
|
||||
c.Line, c.Area = chartPaths(q.series, 1, chartW, chartH, lo, hi)
|
||||
y := func(p float64) float64 { return chartH - (p-lo)/(hi-lo)*chartH }
|
||||
c.HighY, c.LowY = y(q.High), y(q.Low)
|
||||
prices := make([]string, len(q.series))
|
||||
for i, p := range q.series {
|
||||
prices[i] = strconv.FormatFloat(p, 'f', 4, 64)
|
||||
}
|
||||
c.Prices = strings.Join(prices, ",")
|
||||
for i := 1; i <= 3; i++ {
|
||||
p := lo + (hi-lo)*float64(i)/4
|
||||
c.Grid = append(c.Grid, GridLine{Y: y(p), Top: y(p) / chartH * 100, Label: formatCredits(p)})
|
||||
}
|
||||
for h := 0; h <= 24; h += 6 {
|
||||
label := fmt.Sprintf("−%dh", 24-h)
|
||||
if h == 24 {
|
||||
label = "Now"
|
||||
}
|
||||
c.Times = append(c.Times, TimeLabel{X: float64(h) / 24 * 100, Label: label})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// chartPaths returns an SVG line path through every nth point of series, scaled to
|
||||
// w×h between lo and hi, and the area under it.
|
||||
func chartPaths(series []float64, every int, w, h, lo, hi float64) (line, area string) {
|
||||
if len(series) < 2 {
|
||||
return "", ""
|
||||
}
|
||||
if hi <= lo {
|
||||
lo, hi = lo-1, hi+1
|
||||
}
|
||||
var b strings.Builder
|
||||
last := len(series) - 1
|
||||
for i := 0; i <= last; i += every {
|
||||
if i+every > last {
|
||||
i = last
|
||||
}
|
||||
x := float64(i) / float64(last) * w
|
||||
y := h - (series[i]-lo)/(hi-lo)*h
|
||||
cmd := "L"
|
||||
if i == 0 {
|
||||
cmd = "M"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%.1f %.1f", cmd, x, y)
|
||||
}
|
||||
line = b.String()
|
||||
return line, fmt.Sprintf("%sL%.1f %.1fL0 %.1fZ", line, w, h, h)
|
||||
func marketChart(now int64) MarketChart {
|
||||
return MarketChart{Step: marketStep.Milliseconds(), Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// An event on every resource counts once, also when asked for "all" (the cycle speed).
|
||||
func TestPriceBoostsAllCountsOnce(t *testing.T) {
|
||||
b := PriceBoosts{"all": 2, "Azurium": 3}
|
||||
if got := b.For("all"); got != 2 {
|
||||
t.Errorf(`For("all") = %v, want 2`, got)
|
||||
}
|
||||
if got := b.For("Azurium"); got != 6 {
|
||||
t.Errorf(`For("Azurium") = %v, want 6`, got)
|
||||
}
|
||||
if got := b.For("Sollux"); got != 2 {
|
||||
t.Errorf(`For("Sollux") = %v, want 2`, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMarketCache: the cached series match the uncached rates, and a new config snapshot
|
||||
// (the economy editor's override) is picked up at once.
|
||||
func TestMarketCache(t *testing.T) {
|
||||
old := cfg()
|
||||
defer effective.Store(old)
|
||||
now := marketTick(time.Now())
|
||||
item := "Sollux"
|
||||
m := marketSeriesAt(item, now)
|
||||
for _, tick := range []int64{now, now - 1, now - marketPoints + 1} {
|
||||
if got, want := marketRateAt(item, tick), rateAt(old, item, tick); got != want {
|
||||
t.Errorf("tick %d: cached %v, computed %v", tick, got, want)
|
||||
}
|
||||
}
|
||||
if m.rate != rateAt(old, item, now) || m.open != rateAt(old, item, now-marketPoints+1) {
|
||||
t.Errorf("series ends %v %v", m.open, m.rate)
|
||||
}
|
||||
c := baseConfig()
|
||||
c.ResourcePrices[item] *= 2
|
||||
effective.Store(&c)
|
||||
if got, want := marketSeriesAt(item, now).rate, rateAt(&c, item, now); got != want || got == m.rate {
|
||||
t.Errorf("after a config change: %v, want %v (was %v)", got, want, m.rate)
|
||||
}
|
||||
if got := marketRateAt(item, now-5); got != rateAt(&c, item, now-5) {
|
||||
t.Errorf("after a config change, rate %v", got)
|
||||
}
|
||||
}
|
||||
+21
-7
@@ -1,12 +1,15 @@
|
||||
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.
|
||||
// parameters the Defold client's moon generator renders, client/modules/moon/terrain.lua)
|
||||
// and from its wells.
|
||||
//
|
||||
// resolveMoonLook mirrors the part of terrain.js's resolveMoon that matters for the text
|
||||
// (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.
|
||||
// resolveMoonLook mirrors the part of terrain.lua's resolve that matters for the text
|
||||
// (same hash, same PRNG, same draw order, same color math). terrain.lua's traits returns
|
||||
// the same fields (`moonminers moon-traits` prints Go's side for comparing). The function
|
||||
// names below (resolveMoon, moonTraits, hashString, rng, adjustColor, mergeSettings) are
|
||||
// those of the old terrain.js, which terrain.lua ports (resolve, traits, hash_string, rng,
|
||||
// adjust_color, merge_settings).
|
||||
//
|
||||
// Settings: like the client, the saved admin settings (moon_settings) are merged over the
|
||||
// defaults. The description is computed once, when the moon is bought (or backfilled by
|
||||
@@ -268,7 +271,7 @@ type wellSupply struct {
|
||||
|
||||
// classifyMoon turns resolved look parameters (and the defaults they vary from) plus the
|
||||
// wells into traits. Wealth is judged against the expected supply of new wells
|
||||
// (meanWell: the average well of the moon's grade). Thresholds were checked by eye in the moon lab (/utils/moons).
|
||||
// (meanWell: the average well of the moon's grade). Thresholds were checked by eye in the old web moon lab.
|
||||
func classifyMoon(base moonLook, r resolvedLook, wells []wellSupply, meanWell float64) moonTraits {
|
||||
var t moonTraits
|
||||
|
||||
@@ -566,7 +569,7 @@ func backfillMoonDescriptions(ctx context.Context, db *pgxpool.Pool) error {
|
||||
|
||||
// ---------------------------------------------------------------- check / samples
|
||||
|
||||
// runMoonTraits is the `moon-traits` command, used by scripts/check-moon-traits.mjs.
|
||||
// runMoonTraits is the `moon-traits` command, for comparing with terrain.lua's traits.
|
||||
// It reads {"settings": {...} | null, "seeds": [...]} on stdin and prints the resolved
|
||||
// look of each seed as JSON. With "samples": true it instead prints each seed's traits and
|
||||
// description, with random wells.
|
||||
@@ -602,3 +605,14 @@ func runMoonTraits() error {
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
// loadMoonDescription loads the description of one of the player's moons into s.Moons
|
||||
// (loadState doesn't read them: only the moon view shows one).
|
||||
func loadMoonDescription(ctx context.Context, q querier, s *State, moonID string) error {
|
||||
m := s.moon(moonID)
|
||||
if m == nil || m.Description != "" {
|
||||
return nil
|
||||
}
|
||||
return q.QueryRow(ctx, "select description from staking_source where id = $1 and user_id = $2",
|
||||
m.ID, s.UserID).Scan(&m.Description)
|
||||
}
|
||||
+9
-14
@@ -13,13 +13,15 @@ import (
|
||||
"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.
|
||||
// Moon look: the procedural-moon settings (the Defold client's moon generator,
|
||||
// client/modules/moon/terrain.lua, documents the fields). Stored as one JSON row; the client
|
||||
// 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.
|
||||
// With neither, no player is an admin. It's for the API (state.admin, admin-grant,
|
||||
// saving moon settings); the admin pages use the admin login instead (admin.go).
|
||||
func (s *server) isAdmin(userID string) bool {
|
||||
if s.dev {
|
||||
return true
|
||||
@@ -53,15 +55,6 @@ func saveMoonSettings(ctx context.Context, db *pgxpool.Pool, settings json.RawMe
|
||||
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)
|
||||
@@ -77,9 +70,11 @@ func (s *server) handleGetMoonSettings(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(settings)
|
||||
}
|
||||
|
||||
// handleSaveMoonSettings stores the settings (a JSON object) for everyone. Admins only.
|
||||
// handleSaveMoonSettings stores the settings (a JSON object) for everyone. Admins only: a
|
||||
// signed-in admin (the mm_admin cookie), an API player in ADMIN_USER_IDS (X-MM-User, as the
|
||||
// Defold client sends it) or anyone with MM_DEV=1.
|
||||
func (s *server) handleSaveMoonSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.isAdmin(s.currentUser(r)) {
|
||||
if !s.adminSession(r) && !s.isAdmin(r.Header.Get(userHeader)) {
|
||||
http.Error(w, "Missing permission", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
+20
-10
@@ -144,16 +144,31 @@ func actionAllowed(ctx context.Context, db *pgxpool.Pool, userID, action string,
|
||||
if feature == "" {
|
||||
return nil
|
||||
}
|
||||
if feature == "new-moon" {
|
||||
return newMoonAllowed(ctx, db, userID)
|
||||
}
|
||||
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.
|
||||
if !s.Unlocked(feature) {
|
||||
return gameErr("Not available yet")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newMoonAllowed is the new-moon gate: the first moon is always for sale (and the next once
|
||||
// every moon is mined out, see moonFinished), more come with the store. buyMoon checks it
|
||||
// again under the player's row lock, so two buys at once can't both pass it.
|
||||
func newMoonAllowed(ctx context.Context, q querier, userID string) error {
|
||||
s := &State{}
|
||||
err := q.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&s.Onboarding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var active int
|
||||
err := db.QueryRow(ctx, `
|
||||
err = q.QueryRow(ctx, `
|
||||
select count(*) from staking_source
|
||||
where user_id = $1
|
||||
and (exists (select 1 from resource_well where source_id = staking_source.id and supply > 0)
|
||||
@@ -166,12 +181,7 @@ func actionAllowed(ctx context.Context, db *pgxpool.Pool, userID, action string,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if active == 0 {
|
||||
return nil
|
||||
}
|
||||
feature = "store"
|
||||
}
|
||||
if !s.Unlocked(feature) {
|
||||
if active > 0 && !s.Unlocked("store") {
|
||||
return gameErr("Not available yet")
|
||||
}
|
||||
return nil
|
||||
|
||||
+16
-57
@@ -2,8 +2,9 @@ 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).
|
||||
// action runs in a transaction and is logged in event_log as admin_* with "admin": "admin"
|
||||
// (the game's quick grant, admin-grant, logs the admin player's id instead).
|
||||
// Banned players get a 403 from the API (apiUser).
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -34,30 +35,14 @@ func (s *server) playerRoutes(mux *http.ServeMux) {
|
||||
|
||||
// ------------------------------------------------------------------ bans
|
||||
|
||||
func isBanned(ctx context.Context, db *pgxpool.Pool, userID string) bool {
|
||||
// isBanned reports whether the player is banned (no such player: not banned).
|
||||
func isBanned(ctx context.Context, db *pgxpool.Pool, userID string) (bool, error) {
|
||||
var banned bool
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return banned, err
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ list
|
||||
@@ -160,10 +145,6 @@ type PlayersPage struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -174,7 +155,9 @@ func (s *server) handlePlayers(w http.ResponseWriter, r *http.Request) {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
// htmx's search swaps just the list; a history restore (Back past htmx's cache) asks
|
||||
// with HX-Request too but wants the whole page.
|
||||
if r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-History-Restore-Request") != "true" {
|
||||
s.render(w, "pl-list", p)
|
||||
return
|
||||
}
|
||||
@@ -190,13 +173,6 @@ func (s *server) handlePlayers(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -239,7 +215,6 @@ 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
|
||||
@@ -271,7 +246,7 @@ func loadPlayerEvents(ctx context.Context, db *pgxpool.Pool, userID string, page
|
||||
}
|
||||
|
||||
// loadPlayerPage returns nil if there is no such player.
|
||||
func (s *server) loadPlayerPage(ctx context.Context, userID, adminID string) (*PlayerPage, error) {
|
||||
func (s *server) loadPlayerPage(ctx context.Context, userID string) (*PlayerPage, error) {
|
||||
rows, err := s.db.Query(ctx, "select "+playerColumns+" from users"+playerActivity+
|
||||
" where users.id = $1", userID)
|
||||
if err != nil {
|
||||
@@ -283,7 +258,7 @@ func (s *server) loadPlayerPage(ctx context.Context, userID, adminID string) (*P
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := &PlayerPage{Allowed: true, P: p, Self: strings.EqualFold(userID, adminID)}
|
||||
page := &PlayerPage{Allowed: true, P: p}
|
||||
if page.State, err = loadState(ctx, s.db, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -326,17 +301,12 @@ func (p *PlayerPage) StakesOn(wellID string) int {
|
||||
}
|
||||
|
||||
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)
|
||||
page, err := s.loadPlayerPage(r.Context(), id)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
@@ -349,10 +319,6 @@ func (s *server) handlePlayer(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) handlePlayerEvents(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -371,11 +337,7 @@ func (s *server) handlePlayerEvents(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handlePlayerAction runs an admin action on a player and re-renders the detail (#pl-detail).
|
||||
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
|
||||
}
|
||||
adminID := adminLabel
|
||||
ctx := r.Context()
|
||||
id := strings.ToLower(r.PathValue("id"))
|
||||
if !uuidPattern.MatchString(id) {
|
||||
@@ -442,7 +404,7 @@ func (s *server) handlePlayerAction(w http.ResponseWriter, r *http.Request) {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
page, perr := s.loadPlayerPage(ctx, id, adminID)
|
||||
page, perr := s.loadPlayerPage(ctx, id)
|
||||
if perr != nil {
|
||||
serverError(w, perr)
|
||||
return
|
||||
@@ -618,9 +580,6 @@ func adminReset(ctx context.Context, db *pgxpool.Pool, adminID, userID string) e
|
||||
}
|
||||
|
||||
func adminBan(ctx context.Context, db *pgxpool.Pool, adminID, userID string, ban bool) error {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
package main
|
||||
|
||||
// Progression charts for the Economy page's Analysis tab: each chart draws the saved config
|
||||
// and the draft. The payback ladder lists every purchase in the order a player would buy
|
||||
// it, with the minutes of active play it takes to earn its price back.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// jumpRatio: a purchase whose payback is more than this many times its predecessor's is a
|
||||
// jump (a wall) on the ladder.
|
||||
const jumpRatio = 2.0
|
||||
|
||||
// Purchase is one step of the payback ladder.
|
||||
type Purchase struct {
|
||||
Order int // 1 on
|
||||
Kind string // moon, drill, yield, cycle, advisor
|
||||
Name string // e.g. "Moon C", "PX-4", "PX-4 yield 2", "Ada Voss"
|
||||
Grade string // the bracket: the moon grade it belongs with
|
||||
Cost float64
|
||||
GainPerMin float64 // ₥/min it adds (a moon: what its drill earns on it)
|
||||
Payback float64 // minutes; +Inf if it adds nothing
|
||||
Jump float64 // payback ÷ the previous purchase's (advisors left out), when over jumpRatio; else 0
|
||||
Note string
|
||||
}
|
||||
|
||||
// Ladder is a config's purchases in buying order, and what was left out.
|
||||
type Ladder struct {
|
||||
Steps []Purchase
|
||||
Skipped []string // purchases with no direct income or no seat
|
||||
Stats []GradeStat
|
||||
}
|
||||
|
||||
// payLadder builds the ladder. Order: for each moon grade (cheapest first), its moon, then
|
||||
// each drill meant for it (suitedGrade, by price), each followed by its track levels
|
||||
// (the cheaper next level of the two tracks first), then the advisors the player can afford
|
||||
// by then (best payback first; the first seat is free, each later one adds its seat price).
|
||||
// Income is one drill on moons of its grade, net of what the moons cost (gradeStats):
|
||||
// a drill's own rate; a level, what it adds over the drill's levels bought so far; an
|
||||
// advisor, what its bonus adds to a full moon (one per well) of the newest drill; a moon, its price over the rate of the
|
||||
// first drill meant for it (the drill bought so far if none is).
|
||||
func payLadder(c *GameConfig) Ladder {
|
||||
var l Ladder
|
||||
if len(c.MoonGrades) == 0 || len(c.ResourcePrices) == 0 {
|
||||
return l
|
||||
}
|
||||
// Per grade: what claims get (reach caps), the value of a unit and the share of it
|
||||
// left after paying for the moon (1 - price/value).
|
||||
stats := gradeStats(c)
|
||||
l.Stats = stats
|
||||
caps := map[*MoonGrade]ReachCaps{}
|
||||
rate := func(it *StoreItem, lv Levels, b Bonus) float64 {
|
||||
g := suitedGrade(c, it)
|
||||
if caps[g] == nil {
|
||||
caps[g] = reachCaps(c, g)
|
||||
}
|
||||
st := stats[c.gradeIndex(g.Grade)]
|
||||
net := 0.0
|
||||
if st.Mean > 0 {
|
||||
net = max(0, 1-1/st.Mean)
|
||||
}
|
||||
secs, y := drillStats(it.ToolSecs(lv), it.ToolYield(lv), b, 1, 1)
|
||||
return perHour(caps[g].Yield(y, it.ToolReach()), secs) * st.Unit * net * b.Sell / 60
|
||||
}
|
||||
byGrade := map[string][]int{}
|
||||
order := make([]int, len(c.StoreItems))
|
||||
for i := range order {
|
||||
order[i] = i
|
||||
}
|
||||
slices.SortStableFunc(order, func(a, b int) int {
|
||||
return cmpAsc(c.StoreItems[a].Price, c.StoreItems[b].Price)
|
||||
})
|
||||
for _, i := range order {
|
||||
g := suitedGrade(c, &c.StoreItems[i]).Grade
|
||||
byGrade[g] = append(byGrade[g], i)
|
||||
}
|
||||
advisors := map[string]bool{}
|
||||
for _, a := range c.Advisors.Items {
|
||||
advisors[a.Name] = true
|
||||
}
|
||||
hired := 0
|
||||
last := -1 // the newest drill
|
||||
levels := map[int]Levels{}
|
||||
add := func(p Purchase) {
|
||||
p.Order = len(l.Steps) + 1
|
||||
p.Payback = math.Inf(1)
|
||||
if p.GainPerMin > 0 {
|
||||
p.Payback = p.Cost / p.GainPerMin
|
||||
}
|
||||
// Advisors are account-wide, so they're left out of the jumps.
|
||||
if p.Kind != "advisor" {
|
||||
if prev := prevPayback(l.Steps); prev > 0 && !math.IsInf(prev, 0) && p.Payback > jumpRatio*prev {
|
||||
p.Jump = p.Payback / prev
|
||||
}
|
||||
}
|
||||
l.Steps = append(l.Steps, p)
|
||||
}
|
||||
for gi, g := range c.MoonGrades {
|
||||
// The moon.
|
||||
moonRate := 0.0
|
||||
if ds := byGrade[g.Grade]; len(ds) > 0 {
|
||||
moonRate = rate(&c.StoreItems[ds[0]], Levels{}, noBonus)
|
||||
} else if last >= 0 {
|
||||
moonRate = rate(&c.StoreItems[last], levels[last], noBonus)
|
||||
}
|
||||
add(Purchase{Kind: "moon", Name: "Moon " + g.Grade, Grade: g.Grade, Cost: g.Price, GainPerMin: moonRate})
|
||||
// Its drills and their levels.
|
||||
for _, i := range byGrade[g.Grade] {
|
||||
it := &c.StoreItems[i]
|
||||
code := toolCode(it.Name)
|
||||
add(Purchase{Kind: "drill", Name: code, Grade: g.Grade, Cost: it.Price, GainPerMin: rate(it, Levels{}, noBonus)})
|
||||
last = i
|
||||
lv := Levels{}
|
||||
for {
|
||||
kind := ""
|
||||
var cost float64
|
||||
for _, k := range trackKinds {
|
||||
if u := it.TrackLevel(k, lv.Of(k)+1); u != nil {
|
||||
if p := u.Price + partsValue(c, u.Parts); kind == "" || p < cost {
|
||||
kind, cost = k, p
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind == "" {
|
||||
break
|
||||
}
|
||||
next := lv.With(kind, lv.Of(kind)+1)
|
||||
add(Purchase{Kind: kind, Name: fmt.Sprintf("%s %s %d", code, kind, next.Of(kind)), Grade: g.Grade,
|
||||
Cost: cost, GainPerMin: rate(it, next, noBonus) - rate(it, lv, noBonus)})
|
||||
lv = next
|
||||
levels[i] = lv
|
||||
}
|
||||
}
|
||||
// Advisors affordable before the next moon grade (all of them at the last grade).
|
||||
limit := math.Inf(1)
|
||||
if gi+1 < len(c.MoonGrades) {
|
||||
limit = c.MoonGrades[gi+1].Price
|
||||
}
|
||||
// Advisors are account-wide: count their bonus on a full moon's worth of the newest
|
||||
// drill (one per well, the grade's average well count).
|
||||
fleet := max(1, math.Round(stats[gi].Wells))
|
||||
for last >= 0 {
|
||||
seat := 0.0
|
||||
if hired > 0 {
|
||||
if hired-1 >= len(c.Advisors.SeatPrices) {
|
||||
break
|
||||
}
|
||||
seat = c.Advisors.SeatPrices[hired-1]
|
||||
}
|
||||
it := &c.StoreItems[last]
|
||||
base := rate(it, levels[last], noBonus)
|
||||
best, bestGain := -1, 0.0
|
||||
for ai, a := range c.Advisors.Items {
|
||||
if !advisors[a.Name] {
|
||||
continue
|
||||
}
|
||||
gain := rate(it, levels[last], c.bonusesFor([]string{a.Name})) - base
|
||||
if gain > 0 && (best < 0 || (a.Price+seat)/gain < (c.Advisors.Items[best].Price+seat)/bestGain) {
|
||||
best, bestGain = ai, gain
|
||||
}
|
||||
}
|
||||
if best < 0 || c.Advisors.Items[best].Price+seat >= limit {
|
||||
break
|
||||
}
|
||||
a := c.Advisors.Items[best]
|
||||
p := Purchase{Kind: "advisor", Name: a.Name, Grade: g.Grade, Cost: a.Price + seat, GainPerMin: bestGain * fleet}
|
||||
p.Note = fmt.Sprintf("on %.0f drills", fleet)
|
||||
if seat > 0 {
|
||||
p.Note += fmt.Sprintf(", with seat %d (%s)", hired+1, formatCredits(seat))
|
||||
}
|
||||
add(p)
|
||||
delete(advisors, a.Name)
|
||||
hired++
|
||||
}
|
||||
}
|
||||
for _, a := range c.Advisors.Items {
|
||||
if advisors[a.Name] {
|
||||
l.Skipped = append(l.Skipped, a.Name+" (advisor: no ₥ income, or no seat left)")
|
||||
}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// prevPayback is the payback of the last step that isn't an advisor, 0 if none.
|
||||
func prevPayback(steps []Purchase) float64 {
|
||||
for i := len(steps) - 1; i >= 0; i-- {
|
||||
if steps[i].Kind != "advisor" {
|
||||
return steps[i].Payback
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmpAsc(a, b float64) int {
|
||||
switch {
|
||||
case a < b:
|
||||
return -1
|
||||
case a > b:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// minutesText is a payback for labels: "40s", "12 min", "3.5 h".
|
||||
func minutesText(m float64) string {
|
||||
switch {
|
||||
case math.IsInf(m, 0):
|
||||
return "never"
|
||||
case m < 1:
|
||||
return fmt.Sprintf("%.0fs", m*60)
|
||||
case m < 120:
|
||||
return fmt.Sprintf("%.3g min", m)
|
||||
}
|
||||
return fmt.Sprintf("%.3g h", m/60)
|
||||
}
|
||||
|
||||
// Progression is the chart view for the Analysis tab.
|
||||
type Progression struct {
|
||||
Payback *Chart
|
||||
Jumps []Purchase // the draft's
|
||||
SavedJumps int
|
||||
Skipped []string
|
||||
Same bool // the draft is the saved config
|
||||
JumpRatio float64
|
||||
Tiers []*Chart // per drill tier: price, yield, cycle, units/s
|
||||
Grades []*Chart // per moon grade: price, value, value ÷ price
|
||||
Levels []*Chart // per track and drill: payback per level
|
||||
}
|
||||
|
||||
const (
|
||||
savedClass = "ch-saved"
|
||||
draftClass = "ch-draft"
|
||||
)
|
||||
|
||||
func progression(saved, draft *GameConfig, same bool) *Progression {
|
||||
ls, ld := payLadder(saved), payLadder(draft)
|
||||
p := &Progression{Same: same, Skipped: ld.Skipped, JumpRatio: jumpRatio}
|
||||
ch := &Chart{Title: "Payback per purchase", XLabel: "buying order →", YLabel: "minutes to earn it back",
|
||||
LogY: true, YFmt: func(v float64) string { return minutesText(v) }, Height: 300}
|
||||
series := func(name, class string, l Ladder) ChartSeries {
|
||||
s := ChartSeries{Name: name, Class: class}
|
||||
for _, st := range l.Steps {
|
||||
title := fmt.Sprintf("#%d %s · %s · adds %s/min · pays back in %s", st.Order, st.Name,
|
||||
formatCredits(st.Cost), formatCompact(st.GainPerMin), minutesText(st.Payback))
|
||||
if st.Note != "" {
|
||||
title += " · " + st.Note
|
||||
}
|
||||
pt := ChartPoint{X: float64(st.Order), Y: st.Payback, Title: title, Alt: st.Kind == "advisor"}
|
||||
if st.Jump > 0 {
|
||||
pt.Mark = true
|
||||
pt.Title += fmt.Sprintf(" · ×%.1f the step before", st.Jump)
|
||||
}
|
||||
s.Points = append(s.Points, pt)
|
||||
}
|
||||
return s
|
||||
}
|
||||
draftName := "Draft"
|
||||
if same {
|
||||
draftName = "Draft (same as saved)"
|
||||
}
|
||||
ch.Series = []ChartSeries{series("Saved", savedClass, ls), series(draftName, draftClass, ld)}
|
||||
// Bands: the draft's grade brackets.
|
||||
for i, st := range ld.Steps {
|
||||
if st.Kind == "moon" {
|
||||
end := float64(len(ld.Steps)) + 0.5
|
||||
for _, nx := range ld.Steps[i+1:] {
|
||||
if nx.Kind == "moon" {
|
||||
end = float64(nx.Order) - 0.5
|
||||
break
|
||||
}
|
||||
}
|
||||
ch.Bands = append(ch.Bands, ChartBand{X0: float64(st.Order) - 0.5, X1: end, Label: st.Grade})
|
||||
}
|
||||
}
|
||||
// Label the draft's jumps.
|
||||
for i, st := range ld.Steps {
|
||||
if st.Jump > 0 {
|
||||
ch.Series[1].Points[i].Label = st.Name
|
||||
p.Jumps = append(p.Jumps, st)
|
||||
}
|
||||
}
|
||||
for _, st := range ls.Steps {
|
||||
if st.Jump > 0 {
|
||||
p.SavedJumps++
|
||||
}
|
||||
}
|
||||
p.Payback = ch
|
||||
p.Tiers, p.Grades, p.Levels = curves(saved, draft, ls, ld, draftName)
|
||||
return p
|
||||
}
|
||||
|
||||
// JumpText is a jump for the list under the chart.
|
||||
func (p Purchase) JumpText() string {
|
||||
return fmt.Sprintf("#%d %s: %s, pays back in %s (×%.1f the step before)", p.Order, p.Name,
|
||||
formatCredits(p.Cost), minutesText(p.Payback), p.Jump)
|
||||
}
|
||||
|
||||
// Timeline is the clicker sim (the "boosts" player, gradesim.go) for the saved config and
|
||||
// the draft: credits and income over time, purchases, first moon of each grade, stretches
|
||||
// with nothing to click.
|
||||
type Timeline struct {
|
||||
Credits, Income *Chart
|
||||
Rows []TimelineRow // saved, draft
|
||||
Same bool
|
||||
Grades []string
|
||||
}
|
||||
|
||||
type TimelineRow struct {
|
||||
Name string
|
||||
FirstGrade []string // by grade, "" if never
|
||||
Idle string // longest stretch with nothing to click
|
||||
IdleCount int // stretches of gsIdleShow s or more
|
||||
BuyGap string // longest stretch with nothing to buy
|
||||
Buys int
|
||||
End string // credits earned by the end
|
||||
}
|
||||
|
||||
func simTimeline(saved, draft *GameConfig, same bool) *Timeline {
|
||||
pl := gsPlayers[1]
|
||||
rs := simulatePlayer(saved, pl, gradeStats(saved))
|
||||
rd := rs
|
||||
if !same {
|
||||
rd = simulatePlayer(draft, pl, gradeStats(draft))
|
||||
}
|
||||
tl := &Timeline{Same: same}
|
||||
for _, g := range draft.MoonGrades {
|
||||
tl.Grades = append(tl.Grades, g.Grade)
|
||||
}
|
||||
minFmt := func(v float64) string {
|
||||
if v >= 60 && math.Mod(v, 60) == 0 {
|
||||
return fmt.Sprintf("%.0fh", v/60)
|
||||
}
|
||||
if v >= 60 {
|
||||
return fmt.Sprintf("%.1fh", v/60)
|
||||
}
|
||||
return fmt.Sprintf("%.0fm", v)
|
||||
}
|
||||
ticks := []float64{1, 2, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 360, 480}
|
||||
tl.Credits = &Chart{Title: "Credits over time", XLabel: "time played (log scale) →", YLabel: "credits on hand", LogY: true,
|
||||
LogX: true, XTickVals: ticks,
|
||||
NoPoints: true, YFmt: formatCompact, XFmt: minFmt, StripLabel: "nothing to click"}
|
||||
tl.Income = &Chart{Title: "Income per minute", XLabel: "time played (log scale) →", LogX: true, XTickVals: ticks, YLabel: "₥ earned per minute (2-minute average)",
|
||||
LogY: true, NoPoints: true, YFmt: formatCompact, XFmt: minFmt, Height: 220}
|
||||
names := []string{"Saved", "Draft"}
|
||||
if same {
|
||||
names[1] = "Draft (same as saved)"
|
||||
}
|
||||
for i, r := range []*GSResult{rs, rd} {
|
||||
class := []string{savedClass, draftClass}[i]
|
||||
credits := ChartSeries{Name: names[i], Class: class}
|
||||
income := ChartSeries{Name: names[i], Class: class}
|
||||
const window = 120 / gsSampleEvery
|
||||
for k, s := range r.Samples {
|
||||
credits.Points = append(credits.Points, ChartPoint{X: float64(s.At) / 60, Y: max(s.Credits, 10)})
|
||||
if k >= window {
|
||||
d := s.Earned - r.Samples[k-window].Earned
|
||||
income.Points = append(income.Points, ChartPoint{X: float64(s.At) / 60, Y: max(d/2, 1)})
|
||||
}
|
||||
}
|
||||
tl.Credits.Series = append(tl.Credits.Series, credits)
|
||||
tl.Income.Series = append(tl.Income.Series, income)
|
||||
for _, b := range r.Buys {
|
||||
tl.Credits.Ticks = append(tl.Credits.Ticks, ChartTick{X: float64(b.At) / 60, Row: i, Class: class,
|
||||
Title: fmt.Sprintf("%s · %s: %s (%s) %s", names[i], SimTime(b.At), b.Name, b.Kind, formatCredits(b.Cost))})
|
||||
}
|
||||
for _, x := range r.Idles {
|
||||
tl.Credits.Strip = append(tl.Credits.Strip, ChartRange{X0: float64(x[0]) / 60, X1: float64(x[1]) / 60, Row: i, Class: class,
|
||||
Title: fmt.Sprintf("%s · nothing to click from %s for %ds", names[i], SimTime(x[0]), x[1]-x[0])})
|
||||
}
|
||||
row := TimelineRow{Name: names[i], Idle: r.Idle.String(), IdleCount: len(r.Idles), BuyGap: r.BuyGap.String(), Buys: len(r.Buys)}
|
||||
if n := len(r.Samples); n > 0 {
|
||||
row.End = formatCompact(r.Samples[n-1].Earned)
|
||||
}
|
||||
for _, g := range tl.Grades {
|
||||
at, ok := r.FirstGrade[g]
|
||||
if !ok {
|
||||
row.FirstGrade = append(row.FirstGrade, "")
|
||||
continue
|
||||
}
|
||||
row.FirstGrade = append(row.FirstGrade, at.String())
|
||||
for _, ch := range []*Chart{tl.Credits, tl.Income} {
|
||||
ch.VLines = append(ch.VLines, ChartVLine{X: float64(at) / 60, Class: class, Label: g,
|
||||
Title: fmt.Sprintf("%s: first %s moon at %s", names[i], g, at)})
|
||||
}
|
||||
}
|
||||
tl.Rows = append(tl.Rows, row)
|
||||
}
|
||||
return tl
|
||||
}
|
||||
|
||||
// catChart is a small chart of one value per category (drill tiers, grades, levels), saved
|
||||
// against the draft. Labels name the draft's categories.
|
||||
func catChart(title, ylabel string, logY bool, labels []string, saved, draft []float64, yf func(float64) string, tip func(k, i int, v float64) string, draftName string) *Chart {
|
||||
ch := &Chart{Title: title, YLabel: ylabel, LogY: logY, YFmt: yf, Width: 360, Height: 200,
|
||||
XFmt: func(v float64) string {
|
||||
if i := int(v) - 1; i >= 0 && i < len(labels) && float64(i+1) == v {
|
||||
return labels[i]
|
||||
}
|
||||
return ""
|
||||
}}
|
||||
for i := range labels {
|
||||
ch.XTickVals = append(ch.XTickVals, float64(i+1))
|
||||
}
|
||||
for k, vals := range [][]float64{saved, draft} {
|
||||
s := ChartSeries{Name: []string{"Saved", draftName}[k], Class: []string{savedClass, draftClass}[k]}
|
||||
for i, v := range vals {
|
||||
s.Points = append(s.Points, ChartPoint{X: float64(i + 1), Y: v, Title: s.Name + " · " + tip(k, i, v)})
|
||||
}
|
||||
ch.Series = append(ch.Series, s)
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// curves builds the small multiples: per drill tier (by price), per moon grade, and per drill
|
||||
// and track the payback of each level (from the ladders).
|
||||
func curves(saved, draft *GameConfig, ls, ld Ladder, draftName string) (tiers, grades, levels []*Chart) {
|
||||
type tier struct {
|
||||
code string
|
||||
price, claim, secs, perSec float64
|
||||
}
|
||||
tiersOf := func(c *GameConfig) []tier {
|
||||
capsBy := map[*MoonGrade]ReachCaps{}
|
||||
var out []tier
|
||||
for _, it := range c.StoreItems {
|
||||
g := suitedGrade(c, &it)
|
||||
if capsBy[g] == nil {
|
||||
capsBy[g] = reachCaps(c, g)
|
||||
}
|
||||
caps := capsBy[g]
|
||||
secs := float64(it.CompletionTimeInSecs)
|
||||
out = append(out, tier{toolCode(it.Name), it.Price, it.ClaimAmount, secs,
|
||||
caps.Yield(it.ClaimAmount, it.ToolReach()) / max(secs, 1)})
|
||||
}
|
||||
slices.SortStableFunc(out, func(a, b tier) int { return cmpAsc(a.price, b.price) })
|
||||
return out
|
||||
}
|
||||
ts, td := tiersOf(saved), tiersOf(draft)
|
||||
var labels []string
|
||||
for _, t := range td {
|
||||
labels = append(labels, t.code)
|
||||
}
|
||||
pick := func(ts []tier, f func(tier) float64) []float64 {
|
||||
var out []float64
|
||||
for _, t := range ts {
|
||||
out = append(out, f(t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Each series' points are named by their own config's tiers (k: 0 saved, 1 draft).
|
||||
tierChart := func(title, ylabel string, logY bool, f func(tier) float64, yf func(float64) string) *Chart {
|
||||
return catChart(title, ylabel, logY, labels, pick(ts, f), pick(td, f), yf,
|
||||
func(k, i int, v float64) string { return fmt.Sprintf("%s: %s", [][]tier{ts, td}[k][i].code, yf(v)) }, draftName)
|
||||
}
|
||||
num := func(v float64) string { return formatCompact(v)[len(currency):] }
|
||||
tiers = []*Chart{
|
||||
tierChart("Price", "₥", true, func(t tier) float64 { return t.price }, formatCompact),
|
||||
tierChart("Yield per cycle", "units", true, func(t tier) float64 { return t.claim }, num),
|
||||
tierChart("Cycle time", "seconds", false, func(t tier) float64 { return t.secs }, func(v float64) string { return fmt.Sprintf("%gs", v) }),
|
||||
tierChart("Units per second", "on a moon of its grade", true, func(t tier) float64 { return t.perSec }, num),
|
||||
}
|
||||
|
||||
gradesOf := func(c *GameConfig) []string {
|
||||
var out []string
|
||||
for _, g := range c.MoonGrades {
|
||||
out = append(out, g.Grade)
|
||||
}
|
||||
return out
|
||||
}
|
||||
glabels := gradesOf(draft)
|
||||
gnames := [][]string{gradesOf(saved), glabels} // per series, as the ladders'
|
||||
gname := func(k, i int) string {
|
||||
if i < len(gnames[k]) {
|
||||
return gnames[k][i]
|
||||
}
|
||||
return fmt.Sprint(i + 1)
|
||||
}
|
||||
gvals := func(l Ladder, f func(GradeStat) float64) []float64 {
|
||||
var out []float64
|
||||
for _, st := range l.Stats {
|
||||
out = append(out, f(st))
|
||||
}
|
||||
return out
|
||||
}
|
||||
gtip := func(k, i int, v float64) string {
|
||||
return fmt.Sprintf("%s: %s", gname(k, i), formatCompact(v))
|
||||
}
|
||||
grades = []*Chart{
|
||||
catChart("Moon price", "₥", true, glabels, gvals(ls, func(g GradeStat) float64 { return g.Price }),
|
||||
gvals(ld, func(g GradeStat) float64 { return g.Price }), formatCompact, gtip, draftName),
|
||||
catChart("Expected value", "₥ of ore at base prices", true, glabels, gvals(ls, func(g GradeStat) float64 { return g.Mean * g.Price }),
|
||||
gvals(ld, func(g GradeStat) float64 { return g.Mean * g.Price }), formatCompact, gtip, draftName),
|
||||
catChart("Value ÷ price", "× (p10 to p90 in the tooltip)", false, glabels, gvals(ls, func(g GradeStat) float64 { return g.Mean }),
|
||||
gvals(ld, func(g GradeStat) float64 { return g.Mean }), func(v float64) string { return fmt.Sprintf("%.2g×", v) },
|
||||
func(k, i int, v float64) string {
|
||||
st := []Ladder{ls, ld}[k].Stats
|
||||
if i < len(st) {
|
||||
return fmt.Sprintf("%s: %.2f× (p10 %.2f×, p90 %.2f×)", gname(k, i), v, st[i].P10, st[i].P90)
|
||||
}
|
||||
return fmt.Sprintf("%.2f×", v)
|
||||
}, draftName),
|
||||
}
|
||||
|
||||
// Levels: per drill (the draft's, by price) and track, cost ÷ gain of each level.
|
||||
levelVals := func(l Ladder, code, kind string) ([]float64, []Purchase) {
|
||||
var out []float64
|
||||
var steps []Purchase
|
||||
for _, st := range l.Steps {
|
||||
if st.Kind == kind && strings.HasPrefix(st.Name, code+" ") {
|
||||
out = append(out, st.Payback)
|
||||
steps = append(steps, st)
|
||||
}
|
||||
}
|
||||
return out, steps
|
||||
}
|
||||
for _, kind := range trackKinds {
|
||||
for _, t := range td {
|
||||
vs, ss := levelVals(ls, t.code, kind)
|
||||
vd, sd := levelVals(ld, t.code, kind)
|
||||
var lab []string
|
||||
for i := range max(len(vs), len(vd)) {
|
||||
lab = append(lab, fmt.Sprint(i+1))
|
||||
}
|
||||
ch := catChart(t.code+" "+kind, "min to pay back", true, lab, vs, vd, minutesText, func(k, i int, v float64) string {
|
||||
if steps := [][]Purchase{ss, sd}[k]; i < len(steps) {
|
||||
st := steps[i]
|
||||
return fmt.Sprintf("level %d: %s for +%s/min, pays back in %s", i+1, formatCredits(st.Cost), formatCompact(st.GainPerMin), minutesText(v))
|
||||
}
|
||||
return fmt.Sprintf("level %d: %s", i+1, minutesText(v))
|
||||
}, draftName)
|
||||
ch.Width, ch.Height = 280, 170
|
||||
levels = append(levels, ch)
|
||||
}
|
||||
}
|
||||
return tiers, grades, levels
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPayLadderOrder(t *testing.T) {
|
||||
c := baseConfig()
|
||||
l := payLadder(&c)
|
||||
if len(l.Steps) == 0 || l.Steps[0].Name != "Moon "+c.MoonGrades[0].Grade {
|
||||
t.Fatalf("ladder should start with the cheapest moon: %+v", l.Steps[:1])
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
level := map[string]int{} // "PD-1 yield" -> last level
|
||||
grade := -1
|
||||
for i, st := range l.Steps {
|
||||
if st.Order != i+1 {
|
||||
t.Fatalf("step %d has order %d", i, st.Order)
|
||||
}
|
||||
switch st.Kind {
|
||||
case "moon":
|
||||
g := c.gradeIndex(st.Grade)
|
||||
if g != grade+1 {
|
||||
t.Fatalf("moon %s out of order", st.Grade)
|
||||
}
|
||||
grade = g
|
||||
case "drill":
|
||||
seen[st.Name] = true
|
||||
case "yield", "cycle":
|
||||
f := strings.Fields(st.Name) // code kind n
|
||||
if !seen[f[0]] {
|
||||
t.Errorf("%s before its drill", st.Name)
|
||||
}
|
||||
key := f[0] + " " + f[1]
|
||||
level[key]++
|
||||
if want := f[0] + " " + f[1] + " " + itoa(level[key]); st.Name != want {
|
||||
t.Errorf("got %s, want %s", st.Name, want)
|
||||
}
|
||||
}
|
||||
if c.gradeIndex(st.Grade) != grade {
|
||||
t.Errorf("%s in bracket %s after moon %d", st.Name, st.Grade, grade)
|
||||
}
|
||||
}
|
||||
if grade != len(c.MoonGrades)-1 {
|
||||
t.Errorf("not every moon grade: %d", grade)
|
||||
}
|
||||
for _, it := range c.StoreItems {
|
||||
if !seen[toolCode(it.Name)] {
|
||||
t.Errorf("drill %s missing", it.Name)
|
||||
}
|
||||
top := it.MaxLevels()
|
||||
if level[toolCode(it.Name)+" yield"] != top.Yield || level[toolCode(it.Name)+" cycle"] != top.Cycle {
|
||||
t.Errorf("%s: not every level", it.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
func TestPayLadderPayback(t *testing.T) {
|
||||
c := baseConfig()
|
||||
l := payLadder(&c)
|
||||
it := &c.StoreItems[0] // PD-1, meant for grade D
|
||||
g := suitedGrade(&c, it)
|
||||
st := gradeStats(&c)[c.gradeIndex(g.Grade)]
|
||||
perMin := reachCaps(&c, g).Yield(it.ClaimAmount, 1) * 3600 / float64(it.CompletionTimeInSecs) / 60 * st.Unit * (1 - 1/st.Mean)
|
||||
for _, p := range l.Steps {
|
||||
if p.Name == toolCode(it.Name) {
|
||||
if want := it.Price / perMin; math.Abs(p.Payback-want) > 1e-6*want {
|
||||
t.Errorf("PD-1 payback %v min, want %v", p.Payback, want)
|
||||
}
|
||||
if p.Payback <= 0 || p.Payback > 60 {
|
||||
t.Errorf("PD-1 payback %v min out of range", p.Payback)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("no PD-1 step")
|
||||
}
|
||||
|
||||
func TestPayLadderJumps(t *testing.T) {
|
||||
c := baseConfig()
|
||||
l := payLadder(&c)
|
||||
for i, st := range l.Steps {
|
||||
if i == 0 {
|
||||
if st.Jump != 0 {
|
||||
t.Error("first step can't jump")
|
||||
}
|
||||
continue
|
||||
}
|
||||
prev := prevPayback(l.Steps[:i])
|
||||
want := st.Kind != "advisor" && !math.IsInf(prev, 0) && st.Payback > jumpRatio*prev
|
||||
if (st.Jump > 0) != want {
|
||||
t.Errorf("%s: jump %v, payback %v after %v", st.Name, st.Jump, st.Payback, prev)
|
||||
}
|
||||
}
|
||||
// A drill 100× dearer is a wall.
|
||||
c.StoreItems[1].Price *= 100
|
||||
for _, st := range payLadder(&c).Steps {
|
||||
if st.Name == toolCode(c.StoreItems[1].Name) {
|
||||
if st.Jump < jumpRatio {
|
||||
t.Errorf("%s at 100× price: jump %v", st.Name, st.Jump)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("drill missing")
|
||||
}
|
||||
|
||||
// The tier charts name each series' points from that series' own config: a draft with no
|
||||
// drills used to index an empty label list, and a new cheapest drill shifted the saved names.
|
||||
func TestCurvesTierNames(t *testing.T) {
|
||||
saved := baseConfig()
|
||||
empty := baseConfig()
|
||||
empty.StoreItems, empty.StartingTools = nil, nil
|
||||
progression(&saved, &empty, false) // must not panic
|
||||
|
||||
cheap := baseConfig()
|
||||
extra := cheap.StoreItems[0]
|
||||
extra.Name, extra.Price = "Test Zzpick", 1
|
||||
cheap.StoreItems = append([]StoreItem{extra}, cheap.StoreItems...)
|
||||
tiers, _, _ := curves(&saved, &cheap, payLadder(&saved), payLadder(&cheap), "Draft")
|
||||
low := saved.StoreItems[0]
|
||||
for _, it := range saved.StoreItems {
|
||||
if it.Price < low.Price {
|
||||
low = it
|
||||
}
|
||||
}
|
||||
first := toolCode(low.Name)
|
||||
if s := tiers[0].Series[0]; !strings.Contains(s.Points[0].Title, first+":") {
|
||||
t.Errorf("saved point 1 is %q, want the saved cheapest drill %s", s.Points[0].Title, first)
|
||||
}
|
||||
if d := tiers[0].Series[1]; !strings.Contains(d.Points[0].Title, toolCode(extra.Name)+":") {
|
||||
t.Errorf("draft point 1 is %q, want %s", d.Points[0].Title, toolCode(extra.Name))
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package main
|
||||
// bonuses) times moonResale.factor, capped at moonResale.maxRatio times what a
|
||||
// replacement moon would cost. It is compared with that replacement price: a new moon of the
|
||||
// same grade.
|
||||
// Template: "moon-sale" in app.html.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
+25
-6
@@ -1,8 +1,7 @@
|
||||
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.
|
||||
// The new-moon section (a teaser for an unknown moon) and the reveal after a purchase
|
||||
// (the act response's reveal; the client plays it from the data the server sends).
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
@@ -37,6 +36,15 @@ type GradeOffer struct {
|
||||
SupplyMin float64 // a well holds from SupplyMin to just under SupplyMax
|
||||
SupplyMax float64
|
||||
Default bool // what a buy without a grade gets
|
||||
Packs []PackOffer
|
||||
}
|
||||
|
||||
// PackOffer is a moon pack of a grade (config moonPacks), once the store is open.
|
||||
type PackOffer struct {
|
||||
Count int
|
||||
Price float64
|
||||
Discount float64
|
||||
CanAfford bool
|
||||
}
|
||||
|
||||
// GradeOffers lists every moon grade, cheapest first.
|
||||
@@ -45,9 +53,16 @@ func (s *State) GradeOffers() []GradeOffer {
|
||||
var out []GradeOffer
|
||||
for _, g := range cfg().MoonGrades {
|
||||
lo, hi := g.wellCountRange()
|
||||
out = append(out, GradeOffer{Grade: g.Grade, Price: g.Price, CanAfford: s.Balance >= g.Price,
|
||||
o := GradeOffer{Grade: g.Grade, Price: g.Price, CanAfford: s.Balance >= g.Price,
|
||||
WellsMin: lo, WellsMax: hi, SupplyMin: g.WellSupplyMin, SupplyMax: g.WellSupplyMax,
|
||||
Default: g.Grade == def})
|
||||
Default: g.Grade == def}
|
||||
if s.Unlocked("store") {
|
||||
for _, p := range cfg().MoonPacks {
|
||||
price := p.PackPrice(&g)
|
||||
o.Packs = append(o.Packs, PackOffer{Count: p.Count, Price: price, Discount: p.Discount, CanAfford: s.Balance >= price})
|
||||
}
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -200,7 +215,11 @@ func (p *Page) moonReveal() *MoonReveal {
|
||||
rare, rareShare = w, share
|
||||
}
|
||||
}
|
||||
if rare.Resource != "" && rareShare <= 0.15 {
|
||||
switch {
|
||||
case rare.Resource == "" || rareShare > 0.15:
|
||||
case rareShare == 0: // bigger than every sampled well
|
||||
r.Traits = append(r.Traits, fmt.Sprintf("Rare: %s-rich · under 1 in %d moons", rare.Resource, len(odds.values)))
|
||||
default:
|
||||
r.Traits = append(r.Traits, fmt.Sprintf("Rare: %s-rich · 1 in %d moons", rare.Resource, oneIn(rareShare)))
|
||||
}
|
||||
return r
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A well bigger than every sampled one is the rarest, never "1 in 0 moons".
|
||||
func TestRevealRareWellBeyondSample(t *testing.T) {
|
||||
s := veteranState()
|
||||
m := s.Moons[0]
|
||||
p := s.page(View{Section: "moons", Moon: m.ID}.resolve(s))
|
||||
if p.Moon == nil {
|
||||
t.Fatal("no moon on the page")
|
||||
}
|
||||
big := p.Moon.GradeConfig().WellSupplyMax * 100
|
||||
for i := range p.Moon.Wells {
|
||||
p.Moon.Wells[i].Supply = big
|
||||
}
|
||||
found := false
|
||||
for _, tr := range p.moonReveal().Traits {
|
||||
if strings.Contains(tr, "1 in 0 ") {
|
||||
t.Errorf("trait %q", tr)
|
||||
}
|
||||
if strings.HasPrefix(tr, "Rare: ") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no rare trait for wells bigger than any sampled")
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -146,7 +146,10 @@ func (s *server) apiRestore(w http.ResponseWriter, r *http.Request, code string)
|
||||
restoreRefused(w, "That save code doesn't match any miner")
|
||||
return ""
|
||||
}
|
||||
if isBanned(r.Context(), s.db, userID) {
|
||||
if banned, err := isBanned(r.Context(), s.db, userID); err != nil {
|
||||
apiServerError(w, err)
|
||||
return ""
|
||||
} else if banned {
|
||||
// (422, not 403: a client shows 403 as its own player suspended)
|
||||
restoreRefused(w, "That miner is suspended")
|
||||
return ""
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// Checks that moondesc.go (resolveMoonLook) derives the same moon look as terrain.js
|
||||
// (moonTraits) for fixed ids, with default and with custom settings. Run by `make check`.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mergeSettings, moonTraits } from "../web/static/js/terrain.js";
|
||||
|
||||
const seeds = [
|
||||
"00000000-0000-0000-0000-000000000000", "30f76186-83ff-4b26-a429-357bc1ee126f",
|
||||
"6f1c2a9e-0b7d-4c1e-9a52-3d8e4f7a1b20", "a3b2c1d0-1111-4222-8333-944455556666",
|
||||
"d9e8f7a6-b5c4-4d3e-8f2a-1b0c9d8e7f6a", "123e4567-e89b-12d3-a456-426614174000",
|
||||
"f47ac10b-58cc-4372-a567-0e02b2c3d479", "9b2e5c3d-7a1f-4e8b-b6d4-2c0f1e3a5b7d",
|
||||
"c56a4180-65aa-42ec-a945-5fd21dec0538", "1b4e28ba-2fa1-11d2-883f-0016d3cca427",
|
||||
"7d444840-9dc0-11d1-b245-5ffdce74fad2", "e902893a-9d22-3c7e-a7b8-d6e313b71d9f",
|
||||
"b3d4e5f6-0718-4a2b-9c3d-4e5f60718293", "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
|
||||
"0f1e2d3c-4b5a-4968-8776-5a4b3c2d1e0f", "8a7b6c5d-4e3f-4a1b-9c8d-7e6f5a4b3c2d",
|
||||
"2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", "fedcba98-7654-4321-8fed-cba987654321",
|
||||
"moon-lab", "üñí-çødé-🌑",
|
||||
];
|
||||
const custom = {
|
||||
technique: "billow",
|
||||
shading: { mid: "#3a5f8c" },
|
||||
shape: { strength: 0.05 },
|
||||
craters: { count: 90 },
|
||||
variation: { hue: 40, saturation: 1.3, lightness: 0.3, shape: 0.9, craters: 0.2, technique: false },
|
||||
};
|
||||
|
||||
let failed = 0;
|
||||
for (const saved of [null, custom]) {
|
||||
const settings = mergeSettings(saved);
|
||||
const input = JSON.stringify({ settings: saved, seeds });
|
||||
const go = JSON.parse(execFileSync("go", ["run", ".", "moon-traits"], { input, encoding: "utf8" }));
|
||||
seeds.forEach((seed, i) => {
|
||||
const js = moonTraits(settings, seed);
|
||||
for (const [key, want] of Object.entries(js)) {
|
||||
const got = go[i][key];
|
||||
const same = typeof want === "number" ? Math.abs(got - want) <= 1e-12 * Math.max(1, Math.abs(want)) : got === want;
|
||||
if (!same) {
|
||||
failed++;
|
||||
console.error(`moon traits differ for ${seed} (${saved ? "custom" : "default"} settings): ${key} js=${want} go=${got}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (failed) process.exit(1);
|
||||
console.log(`moon traits match for ${seeds.length} ids x 2 settings`);
|
||||
@@ -1,48 +0,0 @@
|
||||
// The Preact client's rule (AGENTS.md, "web/app"): pass signals to JSX as they are and
|
||||
// read `.value` only in logic, computed() and effects. A `.value` read inside JSX makes the
|
||||
// whole component re-render on every change instead of Preact updating one text node or
|
||||
// attribute.
|
||||
//
|
||||
// Flags `.value` inside a JSX expression container in web/app/**/*.jsx (`>{x.value}`,
|
||||
// `attr={x.value}`, a `{x.value …}` child on its own line), up to the first function in
|
||||
// it: event handlers (`onClick={() => x.value++}`) are fine, `{xs.value.map(…)}` isn't.
|
||||
// A guard rail, not a parser: it reads the source line by line, so keep JSX in .jsx files
|
||||
// and Prettier's style (`attr={…}` without spaces; plain JS assignments as `x = {…}`).
|
||||
//
|
||||
// Usage: node scripts/check-signals-jsx.mjs <dir>
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const dir = process.argv[2] ?? "web/app";
|
||||
// A container: opened right after a tag's `>`, an attribute's `=`, or at the start of a
|
||||
// line (a child), up to its closing brace; nested `${…}` is allowed inside.
|
||||
const container = /(?:>|=|^\s*)\{((?:[^{}]|\{[^{}]*\})*)\}?/g;
|
||||
const read = /\.value\b(?!\s*=[^=])/;
|
||||
|
||||
function* files(d) {
|
||||
for (const name of readdirSync(d)) {
|
||||
const p = join(d, name);
|
||||
if (name === "vendor") continue;
|
||||
if (statSync(p).isDirectory()) yield* files(p);
|
||||
else if (p.endsWith(".jsx")) yield p;
|
||||
}
|
||||
}
|
||||
|
||||
let bad = 0;
|
||||
for (const file of files(dir)) {
|
||||
readFileSync(file, "utf8")
|
||||
.split("\n")
|
||||
.forEach((line, i) => {
|
||||
if (/^\s*(\/\/|\*)/.test(line)) return;
|
||||
for (const m of line.matchAll(container)) {
|
||||
// What runs during render: the part before any function in it.
|
||||
const body = m[1].split(/=>|\bfunction\b/)[0];
|
||||
if (read.test(body)) {
|
||||
console.error(`${file}:${i + 1}: .value read inside JSX; pass the signal, or read it in a computed: ${line.trim()}`);
|
||||
bad++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
process.exit(bad ? 1 : 0);
|
||||
+67
-187
@@ -35,6 +35,10 @@ type server struct {
|
||||
newPlayers *rateLimiter
|
||||
// restores limits save code tries per IP (savecode.go).
|
||||
restores *rateLimiter
|
||||
// The admin login (admin.go): ADMIN_PASSWORD, signed-in sessions, tries per IP.
|
||||
adminPassword string
|
||||
admins adminSessions
|
||||
logins *rateLimiter
|
||||
}
|
||||
|
||||
func serve(ctx context.Context) error {
|
||||
@@ -48,7 +52,8 @@ func serve(ctx context.Context) error {
|
||||
}
|
||||
go watchOverride(ctx, db)
|
||||
|
||||
s := &server{db: db, dev: os.Getenv("MM_DEV") == "1", newPlayers: newRateLimiter(10), restores: newRateLimiter(5)}
|
||||
s := &server{db: db, dev: os.Getenv("MM_DEV") == "1", newPlayers: newRateLimiter(10), restores: newRateLimiter(5),
|
||||
adminPassword: os.Getenv("ADMIN_PASSWORD"), logins: newRateLimiter(10)}
|
||||
if s.dev {
|
||||
s.static = os.DirFS("web/static")
|
||||
} else {
|
||||
@@ -63,31 +68,36 @@ func serve(ctx context.Context) error {
|
||||
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)
|
||||
// The game's web build (client: make web) at /, from MM_WEB_DIR (default: the client's
|
||||
// dist/web/MoonMiners beside this checkout); without one, / goes to the admin.
|
||||
webDir := env("MM_WEB_DIR", "../client/dist/web/MoonMiners")
|
||||
if st, err := os.Stat(webDir + "/index.html"); err == nil && !st.IsDir() {
|
||||
// "/", not "GET /": that would conflict with the method-less /utils patterns (panic)
|
||||
mux.Handle("/", http.FileServerFS(os.DirFS(webDir)))
|
||||
} else {
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/utils", http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
mux.HandleFunc("GET /utils/login", s.handleLoginPage)
|
||||
mux.HandleFunc("POST /utils/login", s.handleLogin)
|
||||
mux.HandleFunc("POST /utils/logout", s.handleLogout)
|
||||
// Every other /utils page and action needs an admin session (admin.go).
|
||||
admin := s.adminRoutes()
|
||||
mux.Handle("/utils", s.requireAdmin(admin))
|
||||
mux.Handle("/utils/", s.requireAdmin(admin))
|
||||
mux.HandleFunc("GET /api/moon-settings", s.handleGetMoonSettings)
|
||||
mux.HandleFunc("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)
|
||||
mux.HandleFunc("GET /api/save-code", s.handleAPISaveCode)
|
||||
// The Preact client (web/app, built into web/static/app), next to the htmx one.
|
||||
mux.HandleFunc("GET /app", func(w http.ResponseWriter, r *http.Request) { s.render(w, "spa", nil) })
|
||||
|
||||
var handler http.Handler = mux
|
||||
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")
|
||||
addr := os.Getenv("HOST") + ":" + env("PORT", "3000") // HOST unset: every interface
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: logRequests(secureHeaders(limitBody(handler))),
|
||||
@@ -96,7 +106,11 @@ func serve(ctx context.Context) error {
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
// ListenAndServe returns as soon as Shutdown starts: wait for running handlers to
|
||||
// finish (done) before returning, which closes the pool.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
<-ctx.Done()
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -106,6 +120,7 @@ func serve(ctx context.Context) error {
|
||||
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
<-done
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -132,13 +147,12 @@ func (s *server) render(w http.ResponseWriter, name string, data any) {
|
||||
}
|
||||
}
|
||||
|
||||
// delayGame holds every game request (the htmx page's swaps and actions, the JSON API) for
|
||||
// d before answering, to try the clients with a slow connection (MM_API_DELAY_MS, dev only).
|
||||
// delayGame holds every game request (the JSON API's state and actions) for d before
|
||||
// answering, to try the client with a slow connection (MM_API_DELAY_MS, dev only).
|
||||
func delayGame(next http.Handler, d time.Duration) http.Handler {
|
||||
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") {
|
||||
if strings.HasPrefix(p, "/api/act/") || p == "/api/state" {
|
||||
time.Sleep(d)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -255,8 +269,9 @@ func clientIP(r *http.Request) string {
|
||||
}
|
||||
|
||||
// normalIP is an address (with or without a port) as one key per client: IPv4-mapped IPv6
|
||||
// as its IPv4, no zone, and every loopback address (127.0.0.0/8, ::1) as "loopback".
|
||||
// Anything unparsable is kept as it is.
|
||||
// as its IPv4, no zone, every loopback address (127.0.0.0/8, ::1) as "loopback", and an
|
||||
// IPv6 address as its /64 (one client holds a whole /64). Anything unparsable is kept as
|
||||
// it is.
|
||||
func normalIP(s string) string {
|
||||
if host, _, err := net.SplitHostPort(s); err == nil {
|
||||
s = host
|
||||
@@ -269,6 +284,9 @@ func normalIP(s string) string {
|
||||
if ip.IsLoopback() {
|
||||
return "loopback"
|
||||
}
|
||||
if ip.Is6() {
|
||||
return netip.PrefixFrom(ip, 64).Masked().String()
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
@@ -279,15 +297,6 @@ func serverError(w http.ResponseWriter, err error) {
|
||||
|
||||
// ------------------------------------------------------------------ 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,
|
||||
@@ -299,84 +308,13 @@ func setUserCookie(w http.ResponseWriter, userID string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) withUser(h func(w http.ResponseWriter, r *http.Request, userID string)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := s.currentUser(r)
|
||||
if userID == "" {
|
||||
// The page reloads and the boot script signs the player in again.
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
http.Error(w, "Not signed in", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h(w, r, userID)
|
||||
}
|
||||
}
|
||||
|
||||
func newPlayerName() string {
|
||||
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)
|
||||
}
|
||||
// ------------------------------------------------------------------ actions
|
||||
|
||||
// ------------------------------------------------------------------ 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())
|
||||
page.Admin = s.isAdmin(userID)
|
||||
if htmx {
|
||||
page.OOB = true
|
||||
s.render(w, "regions", page)
|
||||
return
|
||||
}
|
||||
s.logSession(r.Context(), userID)
|
||||
s.render(w, "page", page)
|
||||
}
|
||||
|
||||
// actionResult is what runAction did, for the response (HTML regions or JSON).
|
||||
// actionResult is what runAction did, for the API response.
|
||||
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
|
||||
@@ -395,8 +333,8 @@ var errUnknownAction = errors.New("unknown action")
|
||||
// errForbidden is runAction's error for an admin action run by a player who isn't one (403).
|
||||
var errForbidden = errors.New("missing permission")
|
||||
|
||||
// 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
|
||||
// runAction runs a game action (POST /api/act/<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)
|
||||
@@ -415,9 +353,18 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
|
||||
switch action {
|
||||
case "refused":
|
||||
case "buy-moon":
|
||||
var moonID string
|
||||
if moonID, err = buyMoon(ctx, s.db, userID, get("grade")); err == nil {
|
||||
message, view = "New moon claimed!", View{Section: "moons", Moon: moonID}
|
||||
count := 1
|
||||
if c := get("count"); c != "" {
|
||||
if count, err = strconv.Atoi(c); err != nil {
|
||||
count = 0 // (refused: no pack of that size)
|
||||
}
|
||||
}
|
||||
var ids []string
|
||||
if ids, err = buyMoons(ctx, s.db, userID, get("grade"), count); err == nil {
|
||||
message, view = "New moon claimed!", View{Section: "moons", Moon: ids[0]}
|
||||
if len(ids) > 1 {
|
||||
message = fmt.Sprintf("%d new moons claimed!", len(ids))
|
||||
}
|
||||
}
|
||||
case "sell-moon":
|
||||
var name string
|
||||
@@ -615,88 +562,21 @@ func (res actionResult) toast(state *State) *Toast {
|
||||
return toast
|
||||
}
|
||||
|
||||
// handleAction runs a game action and re-renders the screen.
|
||||
func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID string) {
|
||||
ctx := r.Context()
|
||||
res, err := s.runAction(ctx, userID, r.PathValue("action"), r.FormValue)
|
||||
if errors.Is(err, errUnknownAction) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errForbidden) {
|
||||
http.Error(w, "Missing permission", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if res.Action == "new-game" {
|
||||
if !s.newPlayers.allow(r) {
|
||||
http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
newID, err := createUser(ctx, s.db, newPlayerName())
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
setUserCookie(w, newID)
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
return
|
||||
}
|
||||
|
||||
state, v, err := loadView(ctx, s.db, userID, res.View)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
toast := res.toast(state)
|
||||
page := state.page(v)
|
||||
if err := s.loadRanking(ctx, page); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
page.Banner = s.banner(ctx)
|
||||
page.Admin = s.isAdmin(userID)
|
||||
page.OOB = true
|
||||
page.Toast = toast
|
||||
if res.BoughtTool != "" {
|
||||
page.Modal = state.buySuggestions(res.BoughtTool)
|
||||
}
|
||||
if res.Action == "buy-moon" && !toast.Error {
|
||||
page.Reveal = page.moonReveal()
|
||||
}
|
||||
s.render(w, "regions", page)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ admin
|
||||
|
||||
// handleUtils is the admin hub; it opens the Players page (players.go).
|
||||
func (s *server) handleUtils(w http.ResponseWriter, r *http.Request, userID string) {
|
||||
// adminRoutes are the admin pages and actions, behind requireAdmin.
|
||||
func (s *server) adminRoutes() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
// The admin hub opens the Players page (players.go).
|
||||
mux.HandleFunc("GET /utils", func(w http.ResponseWriter, r *http.Request) {
|
||||
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>")
|
||||
})
|
||||
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)
|
||||
return mux
|
||||
}
|
||||
|
||||
// loadRanking fills in the leaderboard section, the one section read from other players.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// The new-player limit keys IPv6 clients by their /64, IPv4 by the address.
|
||||
func TestNormalIP(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"203.0.113.7:4000": "203.0.113.7",
|
||||
"[::ffff:203.0.113.7]:4000": "203.0.113.7",
|
||||
"[2001:db8:1:2:aaaa::1]:4000": "2001:db8:1:2::/64",
|
||||
"2001:db8:1:2:bbbb:cccc::9": "2001:db8:1:2::/64",
|
||||
"[fe80::1%eth0]:80": "fe80::/64",
|
||||
"[::1]:80": "loopback",
|
||||
"127.0.0.2": "loopback",
|
||||
"not an address": "not an address",
|
||||
} {
|
||||
if got := normalIP(in); got != want {
|
||||
t.Errorf("normalIP(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the database can't say whether the player exists, apiUser answers 500 and makes no
|
||||
// new player (the client would save its id over the real one).
|
||||
func TestAPIUserDatabaseDown(t *testing.T) {
|
||||
pool, err := pgxpool.New(context.Background(), "postgres://nobody@127.0.0.1:1/none?connect_timeout=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
s := &server{db: pool, newPlayers: newRateLimiter(10)}
|
||||
r := httptest.NewRequest("GET", "/api/state", nil)
|
||||
r.Header.Set(userHeader, testID("u", 1))
|
||||
w := httptest.NewRecorder()
|
||||
if id, created := s.apiUser(w, r); id != "" || created {
|
||||
t.Fatalf("apiUser = %q, %v; want no player", id, created)
|
||||
}
|
||||
if w.Code != http.StatusInternalServerError || w.Header().Get(userHeader) != "" {
|
||||
t.Fatalf("status %d, %s %q; want 500 and no id", w.Code, userHeader, w.Header().Get(userHeader))
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -139,6 +139,7 @@ type Analysis struct {
|
||||
Tools []ToolStat
|
||||
Refining *RefineryStats
|
||||
Sim *SimResult
|
||||
Progress *Progression // charts, saved config against this one (the handler sets it)
|
||||
// Flags are the warnings by row, for the editor grid: "item:<id>" or "refined:<name>".
|
||||
Flags map[string][]string
|
||||
}
|
||||
@@ -485,6 +486,7 @@ func simulate(c *GameConfig) *SimResult {
|
||||
var wells []*simMoon // unreserved supply by moon
|
||||
left := 0.0 // total unreserved supply
|
||||
moons := 0
|
||||
top := 0 // the dearest grade bought
|
||||
for _, id := range c.StartingTools {
|
||||
if i, ok := itemIndex[id]; ok {
|
||||
tools = append(tools, simTool{item: i})
|
||||
@@ -581,6 +583,7 @@ func simulate(c *GameConfig) *SimResult {
|
||||
break
|
||||
}
|
||||
credits -= moonPrice
|
||||
top = max(top, c.gradeIndex(g.Grade))
|
||||
// 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
|
||||
@@ -604,7 +607,11 @@ func simulate(c *GameConfig) *SimResult {
|
||||
// either track 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)
|
||||
wait := Hours(0) // what the player can pay now needs no income
|
||||
if cost > credits {
|
||||
wait = hours(cost-credits, now)
|
||||
}
|
||||
return wait + hours(cost, gain)
|
||||
}
|
||||
type choice struct {
|
||||
kind string // tool, upgrade, advisor
|
||||
@@ -660,8 +667,10 @@ func simulate(c *GameConfig) *SimResult {
|
||||
}
|
||||
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 < nextGrade(credits).Price {
|
||||
// Keep enough for the next moon: with dry wells and no credits, nothing earns. The
|
||||
// reserve is the dearest grade bought, not one picked from credits, which every
|
||||
// purchase would lower.
|
||||
if best.kind == "" || credits-best.cost < c.MoonGrades[top].Price {
|
||||
break
|
||||
}
|
||||
credits -= best.cost
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// With no starting tools there is no income, but the player can still buy a drill.
|
||||
func TestSimBuysWithoutIncome(t *testing.T) {
|
||||
c := baseConfig()
|
||||
c.StartingTools = nil
|
||||
c.StartingCredits = 2000 // a D moon, a PD-1 and the reserve for the next moon
|
||||
s := simulate(&c)
|
||||
n := 0
|
||||
for _, tl := range s.Tools {
|
||||
n += tl.Count
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatalf("the sim player bought no drill: %v", s.Events)
|
||||
}
|
||||
}
|
||||
|
||||
// The sims read the first store item: a config needs one.
|
||||
func TestValidateNeedsStoreItems(t *testing.T) {
|
||||
c := baseConfig()
|
||||
c.StoreItems, c.StartingTools = nil, nil
|
||||
if err := c.validate(); err == nil {
|
||||
t.Fatal("a config with no store items passed")
|
||||
}
|
||||
}
|
||||
|
||||
// The moon reserve doesn't shrink as the player spends: with credits for two B moons the
|
||||
// player buys a B moon and keeps its price, rather than spending down to a cheaper moon's
|
||||
// at once.
|
||||
func TestSimReserveHolds(t *testing.T) {
|
||||
c := baseConfig()
|
||||
c.StartingCredits = 2 * c.MoonGrades[c.gradeIndex("B")].Price
|
||||
s := simulate(&c)
|
||||
for _, e := range s.Events[1:] {
|
||||
if strings.HasPrefix(e, SimTime(0).String()) {
|
||||
t.Fatalf("bought at once after the first moon: %v", s.Events[:5])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The clicker sim player keeps the dearest moon grade it reached as its reserve, so it
|
||||
// doesn't spend down and value its drills on a cheaper moon later.
|
||||
func TestGradeSimGradeHolds(t *testing.T) {
|
||||
c := baseConfig()
|
||||
stats := gradeStats(&c)
|
||||
for _, pl := range gsPlayers {
|
||||
r := simulatePlayer(&c, pl, stats)
|
||||
top := 0
|
||||
for _, row := range r.Rows {
|
||||
g := c.gradeIndex(row.Grade)
|
||||
if g < top {
|
||||
t.Fatalf("%s player: grade %s at %s, after %s", pl.Name, row.Grade, row.At, c.MoonGrades[top].Grade)
|
||||
}
|
||||
top = g
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wells are stored by resource name, so the config can't price a resource the database
|
||||
// doesn't have.
|
||||
func TestValidateResourceNames(t *testing.T) {
|
||||
c := baseConfig()
|
||||
c.ResourcePrices = maps.Clone(c.ResourcePrices)
|
||||
c.ResourcePrices["Lunarite"] = 30
|
||||
if err := c.validate(); err == nil {
|
||||
t.Fatal("a config with an unknown resource passed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Indexes for the per-request state reads (loadState, loadPastPage) and the stake and
|
||||
-- claim checks: without them each request scanned these tables for every player.
|
||||
create index if not exists staking_source_user on staking_source (user_id);
|
||||
create index if not exists resource_well_source on resource_well (source_id);
|
||||
create index if not exists inventory_item_user on inventory_item (user_id);
|
||||
create index if not exists staking_event_user on staking_event (user_id, created_at);
|
||||
create index if not exists staking_event_well on staking_event (well_id);
|
||||
create index if not exists staking_event_tool on staking_event (inventory_item_id, created_at);
|
||||
@@ -41,6 +41,7 @@ create table staking_source (
|
||||
constraint fk_user_staking_source foreign key(user_id)
|
||||
references users(id) on delete cascade
|
||||
);
|
||||
create index staking_source_user on staking_source (user_id);
|
||||
|
||||
create table resource_well (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
@@ -52,6 +53,7 @@ create table resource_well (
|
||||
constraint fk_rid_resource_well foreign key(resource_id)
|
||||
references resource(id)
|
||||
);
|
||||
create index resource_well_source on resource_well (source_id);
|
||||
|
||||
create table inventory_item (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
@@ -68,6 +70,7 @@ create table inventory_item (
|
||||
constraint fk_store_item_inventory_item foreign key(store_item_id)
|
||||
references store_item(id) on delete cascade
|
||||
);
|
||||
create index inventory_item_user on inventory_item (user_id);
|
||||
|
||||
create table upgrade_event (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
@@ -95,6 +98,9 @@ create table staking_event (
|
||||
constraint fk_iiid_staking_event foreign key(inventory_item_id)
|
||||
references inventory_item(id) on delete cascade
|
||||
);
|
||||
create index staking_event_user on staking_event (user_id, created_at);
|
||||
create index staking_event_well on staking_event (well_id);
|
||||
create index staking_event_tool on staking_event (inventory_item_id, created_at);
|
||||
|
||||
create table claim_event (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package main
|
||||
|
||||
// Template check (make check): parses the embedded templates the way the server does, fails
|
||||
// on a {{template "x"}} that names no template, and renders every page and region against
|
||||
// in-memory game states (no database) so missing fields, methods and functions show up
|
||||
// here instead of in the live game. Run with `go test -run Templates`.
|
||||
// Template check (make check): parses the embedded templates (the admin pages) the way the
|
||||
// server does, fails on a {{template "x"}} that names no template, and renders every page
|
||||
// and region against in-memory states (no database) so missing fields, methods and
|
||||
// functions show up here instead of in production. Run with `go test -run Templates`.
|
||||
// The game states below are also used by api_test.go.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -30,51 +31,14 @@ func TestTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
unlocked := newPlayerState() // everything open but no moon yet
|
||||
unlocked.Onboarding = []string{"all"}
|
||||
early := veteranState() // Equipment open, the Store not yet (a drill panel without its Store parts)
|
||||
early.Onboarding = []string{"equipment", "market"}
|
||||
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState(), early} {
|
||||
for _, v := range testViews(s) {
|
||||
s.pagePastInMemory(v.Page)
|
||||
v = v.resolve(s)
|
||||
p := s.page(v)
|
||||
p.Admin = len(s.Tools) > 0 // the grant dialog and button, for some states
|
||||
if v.Section == "leaderboard" {
|
||||
p.Ranking = &LeaderboardPage{Players: 12, Top: []LeaderboardRow{{Rank: 1, Name: "Top Miner", Balance: 9000}},
|
||||
You: &LeaderboardRow{Rank: 12, Name: "Test Miner", Balance: 10, Me: true}}
|
||||
}
|
||||
render("page", p)
|
||||
// An action response: every region out of band, with a toast, the buy modal
|
||||
// and the moon reveal.
|
||||
p.OOB = true
|
||||
p.Banner = []BannerItem{{Level: "event", Text: "Double yield", Left: "3h left"}}
|
||||
p.Toast = &Toast{Message: "Claimed", Kind: "claim", Finished: "m", Credits: 12}
|
||||
if len(s.Tools) > 0 {
|
||||
p.Modal = s.buySuggestions(s.Tools[0].ID)
|
||||
}
|
||||
p.Reveal = p.moonReveal()
|
||||
render("regions", p)
|
||||
}
|
||||
// The buy modal with no moons or only empty wells.
|
||||
s.pagePastInMemory(1)
|
||||
p := s.page(View{Section: "store"}.resolve(s))
|
||||
p.OOB = true
|
||||
p.Modal = &BuyModal{Item: &s.Config.StoreItems[0], NoMoons: true, AllEmpty: true}
|
||||
render("regions", p)
|
||||
}
|
||||
|
||||
// The Preact client's page (/app).
|
||||
render("spa", nil)
|
||||
|
||||
// Admin pages and the pages outside the game, with and without permission.
|
||||
// The admin pages, with and without permission.
|
||||
s := veteranState()
|
||||
now := time.Now()
|
||||
player := PlayerRow{ID: s.UserID, Name: "Test Miner", CreatedAt: now, LastSeen: &now, BannedAt: &now}
|
||||
events := EventsPage{PlayerID: s.UserID, Events: []PlayerEvent{{ID: 1, Kind: "claim", Data: `{"credits": 1}`, At: now},
|
||||
{ID: 2, Kind: "admin_grant", Data: "{}", At: now}}, Page: 1, More: true}
|
||||
stake := s.Stakes[0]
|
||||
playerPage := &PlayerPage{Allowed: true, Self: true, P: player, State: s, Running: 1, Events: events,
|
||||
playerPage := &PlayerPage{Allowed: true, P: player, State: s, Running: 1, Events: events,
|
||||
Tools: []PlayerTool{{Tool: s.Tools[0], Item: &s.Config.StoreItems[0], Max: Levels{4, 5}, Stake: &stake,
|
||||
MoonName: "Moon", Remaining: time.Minute}, {Tool: s.Tools[1], Item: &s.Config.StoreItems[1]}},
|
||||
StoreItems: s.Config.StoreItems, Grantable: []string{"Sollux"}, Flash: "Done", FlashError: true}
|
||||
@@ -94,9 +58,6 @@ func TestTemplates(t *testing.T) {
|
||||
name string
|
||||
allowed, none any
|
||||
}{
|
||||
{"boot", nil, nil},
|
||||
{"suspended", nil, nil},
|
||||
{"moons-admin", map[string]any{"Allowed": true}, map[string]any{"Allowed": false}},
|
||||
{"players", playersPage, &PlayersPage{}},
|
||||
{"pl-list", playersPage, &PlayersPage{}},
|
||||
{"player", playerPage, &PlayerPage{}},
|
||||
@@ -107,6 +68,7 @@ func TestTemplates(t *testing.T) {
|
||||
{"lo-announcements", liveOps, &LiveOps{}},
|
||||
{"economy", economy, &EconomyPage{}},
|
||||
{"economy-analysis", analyze(cfg(), nil, 2), analyze(cfg(), []string{"bad config"}, 0)},
|
||||
{"login", &LoginPage{Error: "Wrong password.", Next: "/utils"}, &LoginPage{Locked: true}},
|
||||
} {
|
||||
render(page.name, page.allowed)
|
||||
render(page.name, page.none)
|
||||
|
||||
@@ -1,68 +1,55 @@
|
||||
# Endpoints, in Emacs restclient format. The player is identified by the mm_user cookie;
|
||||
# set :user_id to a player id. Game endpoints return HTML fragments for htmx.
|
||||
# Endpoints, in Emacs restclient format. The game is the JSON API (/api/, api.md): the player
|
||||
# is identified by X-MM-User (or the mm_user cookie); set :user_id to a player id. The admin
|
||||
# pages (/utils/) answer HTML (fragments with HX-Request: true).
|
||||
:user_id = 595ab570-fa74-4c6c-980e-4c80d1064dd1
|
||||
:host = http://localhost:3000
|
||||
:api = <<
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
X-MM-User: :user_id
|
||||
#
|
||||
:admin_session = <the mm_admin cookie from POST /utils/login>
|
||||
:headers = <<
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Cookie: mm_user=:user_id
|
||||
Cookie: mm_admin=:admin_session
|
||||
HX-Request: true
|
||||
#
|
||||
|
||||
# Sign in as an existing player (or create a new one if userId is missing/unknown)
|
||||
POST :host/session
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
userId=:user_id
|
||||
|
||||
# A view: s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon, moon=<id>, well=<id>,
|
||||
# drill=<tool id> (equipment: the tool's panel, its upgrade tracks),
|
||||
# pick=<resource or refined item> (market: the selected quote), list=past (moons: the Past tab,
|
||||
# the only way a mined-out moon shows), page=<n> (with list=past: 10 past moons a page, 1 the
|
||||
# most recent), adv=<advisor name> (advisors: a hired advisor's panel)
|
||||
GET :host/?s=store
|
||||
:headers
|
||||
|
||||
# The Preact client (web/app): a page that runs on the JSON API below; same view params
|
||||
GET :host/app?s=moons
|
||||
|
||||
GET :host/?list=past&page=2
|
||||
:headers
|
||||
|
||||
GET :host/?s=advisors&adv=Ada+Voss
|
||||
:headers
|
||||
# Game actions (JSON API, api.md): the player's view goes along as s, moon, well, drill, pick,
|
||||
# list, page, adv (s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon).
|
||||
# Answers {ok, error?, toast, fx?, state}.
|
||||
|
||||
# Buy a moon
|
||||
POST :host/act/buy-moon
|
||||
:headers
|
||||
POST :host/api/act/buy-moon
|
||||
:api
|
||||
|
||||
# Buy a moon of a grade (D, C, B, A, S; without one: the newest moon's grade)
|
||||
POST :host/act/buy-moon
|
||||
:headers
|
||||
POST :host/api/act/buy-moon
|
||||
:api
|
||||
grade=B
|
||||
|
||||
# Sell a moon (not the last one, not right after buying it, no running drills on it)
|
||||
POST :host/act/sell-moon
|
||||
:headers
|
||||
POST :host/api/act/sell-moon
|
||||
:api
|
||||
sell-moon=<moon id>
|
||||
|
||||
# Sell an idle tool (not the only one)
|
||||
POST :host/act/sell-tool
|
||||
:headers
|
||||
POST :host/api/act/sell-tool
|
||||
:api
|
||||
tool=<tool id>
|
||||
|
||||
# Buy a tool
|
||||
POST :host/act/buy-tool
|
||||
:headers
|
||||
POST :host/api/act/buy-tool
|
||||
:api
|
||||
id=1
|
||||
|
||||
# Upgrade a tool: one level on its yield or cycle track (track=yield|cycle)
|
||||
POST :host/act/upgrade
|
||||
:headers
|
||||
POST :host/api/act/upgrade
|
||||
:api
|
||||
tool=2bd5f846-ce1d-45be-998e-26d2e97b7f68&track=yield
|
||||
|
||||
# Deploy a tool on a well
|
||||
POST :host/act/stake
|
||||
:headers
|
||||
POST :host/api/act/stake
|
||||
:api
|
||||
tool=1db181dd-320f-4525-a00d-933e5d72d690&target=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca
|
||||
|
||||
# Claim finished drills on a well (claim-well), a moon (claim-moon) or everywhere (neither).
|
||||
@@ -70,88 +57,88 @@ tool=1db181dd-320f-4525-a00d-933e5d72d690&target=0a7fd54d-02cf-4c08-b106-6f812ad
|
||||
# drills that mine several of its wells (reach).
|
||||
# redeploy=1 puts the tools straight back to work on the same well (drills on a drained well
|
||||
# stay idle; the toast says how many; a multi-well drill moves to the moon's fullest well).
|
||||
POST :host/act/claim
|
||||
:headers
|
||||
POST :host/api/act/claim
|
||||
:api
|
||||
claim-well=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca&redeploy=1
|
||||
|
||||
# Put the idle tools whose last drill was on a well back to work on it
|
||||
POST :host/act/redrill
|
||||
:headers
|
||||
POST :host/api/act/redrill
|
||||
:api
|
||||
redrill-well=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca
|
||||
|
||||
# Sell a resource at the current market price (all=1 sells the whole balance; pick keeps the
|
||||
# market selection)
|
||||
POST :host/act/sell
|
||||
:headers
|
||||
POST :host/api/act/sell
|
||||
:api
|
||||
resource=Sollux&amount=10
|
||||
|
||||
# Sell a refined item (same action; all=1 sells the whole balance)
|
||||
POST :host/act/sell
|
||||
:headers
|
||||
POST :host/api/act/sell
|
||||
:api
|
||||
resource=Dusk+Alloy&amount=1
|
||||
|
||||
# Refinery: start a batch in the lowest free slot (recipe = refined item name; all=1 makes
|
||||
# as many as the inputs allow). Inputs are taken now; collect when it finishes.
|
||||
POST :host/act/refine
|
||||
:headers
|
||||
POST :host/api/act/refine
|
||||
:api
|
||||
recipe=Dusk+Alloy&qty=3
|
||||
|
||||
# Refinery: collect one finished batch (job id), or every finished one without job
|
||||
POST :host/act/collect
|
||||
:headers
|
||||
POST :host/api/act/collect
|
||||
:api
|
||||
job=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca
|
||||
|
||||
# Refinery: buy the next slot (prices in config refinery.slotPrices)
|
||||
POST :host/act/buy-slot
|
||||
:headers
|
||||
POST :host/api/act/buy-slot
|
||||
:api
|
||||
|
||||
# Raise the refinery's batch size to the next upgrade (refinery.batchUpgrades)
|
||||
POST :host/act/upgrade-batch
|
||||
:headers
|
||||
POST :host/api/act/upgrade-batch
|
||||
:api
|
||||
|
||||
# Hire an advisor (config name) into a free seat
|
||||
POST :host/act/hire
|
||||
:headers
|
||||
POST :host/api/act/hire
|
||||
:api
|
||||
advisor=Mira+Castellanos
|
||||
|
||||
# Buy the next advisor seat (prices in config advisors.seatPrices)
|
||||
POST :host/act/buy-seat
|
||||
:headers
|
||||
POST :host/api/act/buy-seat
|
||||
:api
|
||||
|
||||
# Put a hired advisor in an empty seat (seat from 1; moves them if seated elsewhere)
|
||||
POST :host/act/seat-advisor
|
||||
:headers
|
||||
POST :host/api/act/seat-advisor
|
||||
:api
|
||||
advisor=Mira+Castellanos&seat=2
|
||||
|
||||
# Take a seated advisor off their seat (stays hired, no bonus)
|
||||
POST :host/act/unseat-advisor
|
||||
:headers
|
||||
POST :host/api/act/unseat-advisor
|
||||
:api
|
||||
advisor=Mira+Castellanos
|
||||
|
||||
# Swap two hired advisors' seats (one may be unseated)
|
||||
POST :host/act/swap-advisors
|
||||
:headers
|
||||
POST :host/api/act/swap-advisors
|
||||
:api
|
||||
advisor=Mira+Castellanos&with=Ada+Voss
|
||||
|
||||
# Dismiss a hired advisor (frees their seat, nothing refunded)
|
||||
POST :host/act/dismiss-advisor
|
||||
:headers
|
||||
POST :host/api/act/dismiss-advisor
|
||||
:api
|
||||
advisor=Mira+Castellanos
|
||||
|
||||
# Start over as a new player (sets a new cookie)
|
||||
POST :host/act/new-game
|
||||
:headers
|
||||
# Start over as a new player (a new id in X-MM-User)
|
||||
POST :host/api/act/new-game
|
||||
:api
|
||||
|
||||
# Leaderboard (JSON): [{"name", "balance"}], richest first
|
||||
GET :host/api/leaderboard?limit=10
|
||||
|
||||
# JSON API for game clients (api.md). The player id goes in X-MM-User (or the mm_user cookie);
|
||||
# without a known one a player is created and its id comes back in X-MM-User and "userId".
|
||||
# Game state for a view (same params as GET /): {ok, userId, newPlayer?, state}
|
||||
# Game state for a view (the view params above): {ok, userId, newPlayer?, state}
|
||||
GET :host/api/state?moon=<moon id>&well=<well id>
|
||||
X-MM-User: :user_id
|
||||
|
||||
# Run an action (any /act/<action> above, same params, form or JSON body):
|
||||
# Actions take a form or JSON body:
|
||||
# {ok, error?, toast, fx?, state}; a rule violation is a 422 with the state
|
||||
POST :host/api/act/stake
|
||||
X-MM-User: :user_id
|
||||
@@ -159,7 +146,7 @@ Content-Type: application/json
|
||||
|
||||
{"tool": "<tool id>", "target": "<well id>", "moon": "<moon id>", "well": "<well id>"}
|
||||
|
||||
# Claim every finished drill (claim-well, claim-moon, claim-wide, redeploy as for /act/claim)
|
||||
# Claim every finished drill (claim-well, claim-moon, claim-wide, redeploy as above)
|
||||
POST :host/api/act/claim
|
||||
X-MM-User: :user_id
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
@@ -277,13 +264,16 @@ Content-Type: application/json
|
||||
|
||||
{"sell-moon": "<moon id>", "moon": "<moon id>"}
|
||||
|
||||
# Admin: sync the store items in the database with the game config (admin players only)
|
||||
POST :host/utils/load-config
|
||||
:headers
|
||||
# Admin login (ADMIN_PASSWORD): sets the mm_admin cookie and redirects to next (a /utils page).
|
||||
# Without a session, /utils pages redirect here (actions get 401); with MM_DEV=1 no login.
|
||||
POST :host/utils/login
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
# Admin: reset your own progress
|
||||
POST :host/utils/clear-data
|
||||
:headers
|
||||
password=<ADMIN_PASSWORD>&next=/utils/players
|
||||
|
||||
# Admin logout
|
||||
POST :host/utils/logout
|
||||
Cookie: mm_admin=:admin_session
|
||||
|
||||
# Admin hub: redirects to the Players page
|
||||
GET :host/utils
|
||||
@@ -339,23 +329,20 @@ POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/ban
|
||||
POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/unban
|
||||
:headers
|
||||
|
||||
# Admin: moon lab page, a live editor for the procedural moon settings (admins or MM_DEV=1)
|
||||
GET :host/utils/moons
|
||||
:headers
|
||||
|
||||
# Moon settings saved from the moon lab (JSON, null if none); the game merges them over its defaults
|
||||
# Moon settings (JSON, null if none); the Defold client merges them over its defaults
|
||||
GET :host/api/moon-settings
|
||||
|
||||
# Admin: save moon settings (a JSON object; see DEFAULTS in web/static/js/terrain.js)
|
||||
# Admin: save moon settings (a JSON object; see DEFAULTS in client/modules/moon/terrain.lua).
|
||||
# Allowed with the admin cookie, an X-MM-User in ADMIN_USER_IDS, or MM_DEV=1.
|
||||
POST :host/api/moon-settings
|
||||
Content-Type: application/json
|
||||
Cookie: mm_user=:user_id
|
||||
X-MM-User: :user_id
|
||||
|
||||
{"craters": {"count": 400}}
|
||||
|
||||
# Admin: live ops page (dashboard, timed events, announcements; HTML)
|
||||
GET :host/utils/liveops
|
||||
Cookie: mm_user=:user_id
|
||||
Cookie: mm_admin=:admin_session
|
||||
|
||||
# Admin: create (or, with id, update) a timed event. kind: yield_multiplier |
|
||||
# price_multiplier | cycle_multiplier; resource: a resource name or all; times are UTC.
|
||||
|
||||
Vendored
+5187
File diff suppressed because it is too large.
Load diff
+22
-21
@@ -257,7 +257,6 @@ type TrackRow struct {
|
||||
RateFrom float64 // credits per hour (creditsPerHour) now and after the next level
|
||||
RateTo float64
|
||||
Cost float64 // the next level's price plus its parts at sell value
|
||||
Ladder []TrackStep // every level, the other track as it is
|
||||
}
|
||||
|
||||
// TrackStep is one level of a track, for the drill panel's ladder.
|
||||
@@ -270,20 +269,9 @@ type TrackStep struct {
|
||||
Current, Next bool
|
||||
}
|
||||
|
||||
// Cycle is a cycle track.
|
||||
func (r TrackRow) Cycle() bool { return r.Kind == "cycle" }
|
||||
|
||||
// RateGain is what the next level adds per hour.
|
||||
func (r TrackRow) RateGain() float64 { return r.RateTo - r.RateFrom }
|
||||
|
||||
// Payback is how long the next level's extra income takes to repay its cost, "" if never.
|
||||
func (r TrackRow) Payback() string {
|
||||
if gain := r.RateGain(); gain > 0 {
|
||||
return formatSpan(r.Cost / gain * 3600)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Label is the button's text: "Twin rotors 2" (the level it buys).
|
||||
func (r TrackRow) Label() string { return fmt.Sprintf("%s %d", r.Name, r.Level+1) }
|
||||
|
||||
@@ -297,7 +285,7 @@ func (s *State) trackValue(item *StoreItem, lv Levels, kind string) (value, rate
|
||||
return yield, rate
|
||||
}
|
||||
|
||||
// trackRows are the tool's two tracks with their next levels and ladders.
|
||||
// trackRows are the tool's two tracks with their next levels (ladders: trackLadder).
|
||||
func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
|
||||
var rows []TrackRow
|
||||
lv := t.Levels()
|
||||
@@ -308,14 +296,6 @@ func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
|
||||
}
|
||||
r := TrackRow{Kind: kind, Name: tr.Name, Level: lv.Of(kind), Max: len(tr.Levels)}
|
||||
r.Value, r.RateFrom = s.trackValue(item, lv, kind)
|
||||
for n := 0; n <= r.Max; n++ {
|
||||
st := TrackStep{Level: n, Current: n == r.Level, Next: n == r.Level+1}
|
||||
st.Value, st.Rate = s.trackValue(item, lv.With(kind, n), kind)
|
||||
if l := item.TrackLevel(kind, n); l != nil {
|
||||
st.Price, st.Parts = l.Price, s.partRows(l.Parts)
|
||||
}
|
||||
r.Ladder = append(r.Ladder, st)
|
||||
}
|
||||
if next := item.TrackLevel(kind, r.Level+1); next != nil {
|
||||
r.Next = next
|
||||
r.NextValue, r.RateTo = s.trackValue(item, lv.With(kind, r.Level+1), kind)
|
||||
@@ -339,6 +319,27 @@ func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
|
||||
return rows
|
||||
}
|
||||
|
||||
// trackLadder is every level of the item's track (by kind) at the levels lv, the other
|
||||
// track as it is: the same for every tool of that item at those levels (the API sends it
|
||||
// once per item and levels, api_sections.go ladderKey).
|
||||
func (s *State) trackLadder(item *StoreItem, lv Levels, kind string) []TrackStep {
|
||||
var out []TrackStep
|
||||
tr := item.Track(kind)
|
||||
if tr == nil {
|
||||
return out
|
||||
}
|
||||
level := lv.Of(kind)
|
||||
for n := 0; n <= len(tr.Levels); n++ {
|
||||
st := TrackStep{Level: n, Current: n == level, Next: n == level+1}
|
||||
st.Value, st.Rate = s.trackValue(item, lv.With(kind, n), kind)
|
||||
if l := item.TrackLevel(kind, n); l != nil {
|
||||
st.Price, st.Parts = l.Price, s.partRows(l.Parts)
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// upgradedText names what a tool's latest upgrade on the track gave, for the toast.
|
||||
func (s *State) upgradedText(t Tool, kind string) string {
|
||||
item := s.StoreItem(t.StoreItemID)
|
||||
|
||||
+25
-276
@@ -3,9 +3,7 @@ package main
|
||||
// View models for the templates (web/templates/*.html).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"html/template"
|
||||
"math"
|
||||
"net/url"
|
||||
@@ -32,22 +30,11 @@ var resourceColors = map[string]string{
|
||||
// colorPattern is a CSS hex color, the only form config colors may take.
|
||||
var colorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{3,8}$`)
|
||||
|
||||
// templateFuncs are the admin templates' functions.
|
||||
var templateFuncs = template.FuncMap{
|
||||
"money": formatMoney,
|
||||
"cur": formatCredits,
|
||||
// short is the tooltip on a button the player can't afford.
|
||||
"short": func(balance, price float64) string { return shortBy(price - balance) },
|
||||
"mins": func(m int) string { return formatDuration(time.Duration(m) * time.Minute) },
|
||||
"secs": secsText,
|
||||
// A track's value (tracks.go): a cycle ("2.6s") or a yield per cycle ("+5").
|
||||
"trackval": func(cycle bool, v float64) string {
|
||||
if cycle {
|
||||
return formatCycle(v)
|
||||
}
|
||||
return "+" + formatMoney(v)
|
||||
},
|
||||
"curk": formatCompact,
|
||||
"wells": wellsText,
|
||||
"dur": formatDuration,
|
||||
"color": func(resource string) template.CSS {
|
||||
c, ok := resourceColors[resource]
|
||||
@@ -60,30 +47,7 @@ var templateFuncs = template.FuncMap{
|
||||
}
|
||||
return template.CSS("background:" + c)
|
||||
},
|
||||
// planet is the inline style for a moon thumbnail: a hue derived from its id.
|
||||
"planet": func(id string) template.CSS {
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(id))
|
||||
return template.CSS(fmt.Sprintf("--hue:%d", h.Sum32()%360))
|
||||
},
|
||||
// pct is value/total as a CSS width, capped at 100%.
|
||||
"pct": func(value, total float64) template.CSS {
|
||||
if total <= 0 {
|
||||
return "width:0%"
|
||||
}
|
||||
return template.CSS(fmt.Sprintf("width:%.0f%%", math.Min(100, value/total*100)))
|
||||
},
|
||||
"plural": plural,
|
||||
// vals builds an hx-vals JSON object from key/value pairs.
|
||||
"vals": func(pairs ...string) string {
|
||||
m := map[string]string{}
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
m[pairs[i]] = pairs[i+1]
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
},
|
||||
"str": func(v any) string { return fmt.Sprint(v) },
|
||||
}
|
||||
|
||||
// plural returns e.g. "1 drill" or "3 drills".
|
||||
@@ -261,54 +225,13 @@ func (v View) URL() string {
|
||||
return "/?" + q.Encode()
|
||||
}
|
||||
|
||||
// Links to related views, for the templates.
|
||||
func (v View) ToSection(s string) string {
|
||||
return View{Section: s, Moon: v.Moon}.URL()
|
||||
}
|
||||
|
||||
func (v View) ToMoon(id string) string {
|
||||
return View{Section: "moons", Moon: id}.URL()
|
||||
}
|
||||
|
||||
// ToPastMoon opens a finished moon from the Past tab (on the current page of it).
|
||||
func (v View) ToPastMoon(id string) string {
|
||||
return View{Section: "moons", Moon: id, Past: true, Page: v.Page}.URL()
|
||||
}
|
||||
|
||||
func (v View) ToWell(id string) string {
|
||||
return View{Section: "moons", Moon: v.Moon, Well: id}.URL()
|
||||
}
|
||||
|
||||
// ToAdvisor opens a hired advisor's panel in the advisors section.
|
||||
func (v View) ToAdvisor(name string) string {
|
||||
return View{Section: "advisors", Moon: v.Moon, Adv: name}.URL()
|
||||
}
|
||||
|
||||
// ToDrill opens a tool's panel in the equipment section.
|
||||
func (v View) ToDrill(id string) string {
|
||||
return View{Section: "equipment", Moon: v.Moon, Drill: id}.URL()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ page
|
||||
|
||||
// Page is the data for the whole screen. Every htmx response re-renders all of it as
|
||||
// out-of-band swaps (see web/templates/app.html).
|
||||
// RefinedNames lists every refined item, for the grant dialog.
|
||||
func (p *Page) RefinedNames() []string {
|
||||
var names []string
|
||||
for _, item := range cfg().Refinery.Items {
|
||||
names = append(names, item.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Page is the data for one view of the game, mapped to JSON by the API (api.go).
|
||||
type Page struct {
|
||||
S *State
|
||||
V View
|
||||
OOB bool
|
||||
Toast *Toast
|
||||
Scene SceneData
|
||||
Modal *BuyModal // opens after buying a tool
|
||||
|
||||
Banner []BannerItem // active announcements and live events (events.go)
|
||||
Admin bool // the player is an admin: the quick grant dialog (admin-grant)
|
||||
@@ -332,21 +255,20 @@ type Page struct {
|
||||
Advisors *AdvisorsPage // advisors section (advisors.go)
|
||||
Ranking *LeaderboardPage // leaderboard section (leaderboard.go), loaded by the server
|
||||
NewMoon *NewMoonTeaser // new-moon section (reveal.go)
|
||||
Reveal *MoonReveal // the moon just bought (reveal.go)
|
||||
}
|
||||
|
||||
type Toast struct {
|
||||
Message string
|
||||
Error bool
|
||||
Kind string // the action, or "error"; main.js/fx.js plays effects for it
|
||||
// Finished lists the moons the action mined out (comma-separated ids), for fx.js.
|
||||
Kind string // the action, or "error"; the client plays effects for it
|
||||
// Finished lists the moons the action mined out (comma-separated ids).
|
||||
Finished string
|
||||
// Credits is what a sale earned, for the sell ticket's payout effect (fx.js).
|
||||
// Credits is what a sale earned, for the sell ticket's payout effect.
|
||||
Credits float64
|
||||
}
|
||||
|
||||
// SceneData is handed to the three.js viewport (web/static/js/main.js): the moon to show
|
||||
// (its id is the seed) and the selected well.
|
||||
// SceneData is the moon viewport's selection (the API's view.aside): the moon to show (its
|
||||
// id is the seed) and the selected well.
|
||||
type SceneData struct {
|
||||
Select map[string]string `json:"select"` // null hides the viewport
|
||||
}
|
||||
@@ -387,39 +309,6 @@ func (m Moon) FinishedOn() string {
|
||||
return m.FinishedAt.Format("Jan 2")
|
||||
}
|
||||
|
||||
// ActiveURL and PastURL are the moon list's tabs: the first moon of each list.
|
||||
func (p *Page) ActiveURL() string {
|
||||
if len(p.Moons) == 0 {
|
||||
return p.V.ToSection("new-moon")
|
||||
}
|
||||
return p.V.ToMoon(p.Moons[0].ID)
|
||||
}
|
||||
|
||||
func (p *Page) PastURL() string {
|
||||
if p.S.PastTotal == 0 {
|
||||
return ""
|
||||
}
|
||||
return View{Section: "moons", Past: true}.URL()
|
||||
}
|
||||
|
||||
// PastPaging is the Past tab's paging: the page shown, how many there are, and links to
|
||||
// the newer and older pages ("" at either end).
|
||||
type PastPaging struct {
|
||||
Page, Pages, Total int
|
||||
Newer, Older string
|
||||
}
|
||||
|
||||
func (p *Page) PastPaging() PastPaging {
|
||||
pg := PastPaging{Page: p.S.PastPage, Pages: p.S.pastPages(), Total: p.S.PastTotal}
|
||||
if pg.Page > 1 {
|
||||
pg.Newer = View{Section: "moons", Past: true, Page: pg.Page - 1}.URL()
|
||||
}
|
||||
if pg.Page < pg.Pages {
|
||||
pg.Older = View{Section: "moons", Past: true, Page: pg.Page + 1}.URL()
|
||||
}
|
||||
return pg
|
||||
}
|
||||
|
||||
type MoonDetail struct {
|
||||
Moon
|
||||
Wells []WellRow
|
||||
@@ -443,16 +332,6 @@ type DrillGroup struct {
|
||||
|
||||
func (g DrillGroup) Mining() bool { return len(g.Drills) > g.Ready }
|
||||
|
||||
// Yields lists each drill as "end:amount" (end in unix ms), so main.js can show the
|
||||
// amount a Claim button will get as drills finish (display only, capped at the supply).
|
||||
func (g DrillGroup) Yields() string {
|
||||
parts := make([]string, len(g.Drills))
|
||||
for i, d := range g.Drills {
|
||||
parts[i] = strconv.FormatInt(d.End().UnixMilli(), 10) + ":" + strconv.FormatFloat(d.Take, 'f', -1, 64)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func (g *DrillGroup) add(sr StakeRow) {
|
||||
g.Drills = append(g.Drills, sr)
|
||||
if sr.Ready {
|
||||
@@ -476,17 +355,6 @@ type WellRow struct {
|
||||
DryAt time.Time // when that claim can happen
|
||||
}
|
||||
|
||||
// Pending lists when each unfinished drill on the moon finishes (see endsAttr).
|
||||
func (d *MoonDetail) Pending() string {
|
||||
var times []time.Time
|
||||
for _, st := range d.Stakes {
|
||||
if !st.Ready {
|
||||
times = append(times, st.End())
|
||||
}
|
||||
}
|
||||
return endsAttr(times)
|
||||
}
|
||||
|
||||
type WellDetail struct {
|
||||
WellRow
|
||||
MoonWide *DrillGroup // the moon's multi-well drills (claimed together)
|
||||
@@ -529,20 +397,6 @@ type ToolGroup struct {
|
||||
// Short is a deploy card's tool getting less than its yield here (others claim first).
|
||||
func (g ToolGroup) Short() bool { return g.Here < g.Yield-1e-9 }
|
||||
|
||||
// ReachText says how many wells it mines ("" for one).
|
||||
func (g ToolGroup) ReachText() string { return reachLabel(g.Reach) }
|
||||
|
||||
// ReachShort is the reach for a small card: "3 wells", "whole moon" ("" for one).
|
||||
func (g ToolGroup) ReachShort() string {
|
||||
switch {
|
||||
case g.Reach <= 1:
|
||||
return ""
|
||||
case g.Reach >= len(cfg().ResourcePrices):
|
||||
return "whole moon"
|
||||
}
|
||||
return fmt.Sprintf("%d wells", g.Reach)
|
||||
}
|
||||
|
||||
// reachLabel is "Mines 3 wells" or "Strips the whole moon" (a reach as big as a moon can
|
||||
// get); "" for one well.
|
||||
func reachLabel(reach int) string {
|
||||
@@ -571,12 +425,6 @@ type StoreRow struct {
|
||||
BestRate float64 // the same for the player's best tool; 0 with none
|
||||
}
|
||||
|
||||
// RateGain is how much more per hour it earns than the player's best tool.
|
||||
func (r StoreRow) RateGain() float64 { return r.Rate - r.BestRate }
|
||||
|
||||
// ReachText says how many wells the tool mines ("" for one).
|
||||
func (r StoreRow) ReachText() string { return reachLabel(r.Item.ToolReach()) }
|
||||
|
||||
type MarketRow struct {
|
||||
ResourceBalance
|
||||
Refined bool // a refined item (refinery.go)
|
||||
@@ -778,8 +626,7 @@ func (s *State) page(v View) *Page {
|
||||
for _, m := range s.Moons {
|
||||
row := MoonListRow{Moon: m, Selected: m.ID == v.Moon, Over: s.wellsOver(&m)}
|
||||
var pending []time.Time
|
||||
for _, st := range s.Stakes {
|
||||
if st.MoonID == m.ID {
|
||||
for _, st := range s.stakesOn(m.ID) {
|
||||
row.Drills++
|
||||
if st.Remaining(s.Now) == 0 {
|
||||
row.Ready++
|
||||
@@ -790,7 +637,6 @@ func (s *State) page(v View) *Page {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
allPending = append(allPending, pending...)
|
||||
row.Pending = endsAttr(pending)
|
||||
p.Ready += row.Ready
|
||||
@@ -948,14 +794,6 @@ func (s *State) page(v View) *Page {
|
||||
return p
|
||||
}
|
||||
|
||||
// Busy is how many of the row's copies are mining.
|
||||
func (r ToolRow) Busy() int { return r.Count - r.Idle }
|
||||
|
||||
// copyKey is the same for copies of a tool: same type and track levels.
|
||||
func (s *State) copyKey(t Tool) string {
|
||||
return fmt.Sprint(t.StoreItemID, t.Levels())
|
||||
}
|
||||
|
||||
// followTool keeps the selected drill on the tool an action used: the action may have
|
||||
// used an idle copy (pickTool) of the one asked for.
|
||||
func followTool(selected, asked, used string) string {
|
||||
@@ -965,11 +803,6 @@ func followTool(selected, asked, used string) string {
|
||||
return selected
|
||||
}
|
||||
|
||||
// StakeURL opens the well a stake is on.
|
||||
func (p *Page) StakeURL(st *StakeRow) string {
|
||||
return View{Section: "moons", Moon: st.MoonID, Well: st.WellID}.URL()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ after buying a tool
|
||||
|
||||
// BuyModal opens after a tool purchase and suggests wells to deploy it on.
|
||||
@@ -1010,15 +843,21 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
|
||||
var all []Suggestion
|
||||
for i := range s.Moons {
|
||||
moon := &s.Moons[i]
|
||||
remain := s.project(moon).Remain
|
||||
for _, w := range moon.Wells {
|
||||
if w.Supply <= 0 {
|
||||
continue
|
||||
}
|
||||
// What a cycle deployed here would take from each well (several for a reach).
|
||||
// What a cycle deployed here would take from each well (several for a reach),
|
||||
// after the drills already on the moon: none and stakeInTx refuses it.
|
||||
_, yield := s.drill(*t, item, w.Resource)
|
||||
credits := 0.0
|
||||
for j, take := range moon.split(Stake{WellID: w.ID, Amount: yield, Reach: m.Reach}, moon.supply()) {
|
||||
credits, got := 0.0, 0.0
|
||||
for j, take := range moon.split(Stake{WellID: w.ID, Amount: yield, Reach: m.Reach}, remain) {
|
||||
credits += take * s.sellPrice(moon.Wells[j].Resource)
|
||||
got += take
|
||||
}
|
||||
if got <= 1e-9 {
|
||||
continue
|
||||
}
|
||||
row := s.wellRow(moon, w, "")
|
||||
all = append(all, Suggestion{MoonID: moon.ID, MoonName: moon.Name, Well: w,
|
||||
@@ -1039,13 +878,13 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
|
||||
}
|
||||
return a.Supply > b.Supply
|
||||
})
|
||||
var bestRate, bestSupply float64
|
||||
for _, sg := range all {
|
||||
bestRate, bestSupply = max(bestRate, sg.PerMin), max(bestSupply, sg.Supply)
|
||||
}
|
||||
// Only call a well "highest value" when the values actually differ.
|
||||
distinct := all[0].PerMin != all[len(all)-1].PerMin
|
||||
// The labels compare the wells shown; "highest value" only when their values differ.
|
||||
all = all[:min(3, len(all))]
|
||||
bestRate, bestSupply, lowRate := all[0].PerMin, all[0].Supply, all[0].PerMin
|
||||
for _, sg := range all {
|
||||
bestRate, bestSupply, lowRate = max(bestRate, sg.PerMin), max(bestSupply, sg.Supply), min(lowRate, sg.PerMin)
|
||||
}
|
||||
distinct := bestRate != lowRate
|
||||
for i := range all {
|
||||
sg := &all[i]
|
||||
switch {
|
||||
@@ -1063,9 +902,6 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
|
||||
return m
|
||||
}
|
||||
|
||||
// ReachText says how many wells the tool mines ("" for one).
|
||||
func (m *BuyModal) ReachText() string { return reachLabel(m.Reach) }
|
||||
|
||||
// bestToolRate is what the player's best-earning tool makes per hour (creditsPerHour).
|
||||
func (s *State) bestToolRate() float64 {
|
||||
best := 0.0
|
||||
@@ -1128,19 +964,6 @@ func formatCompact(n float64) string {
|
||||
return currency + formatMoney(math.Round(n))
|
||||
}
|
||||
|
||||
// formatSpan formats seconds as "45s", "3m 20s" or "2h 5m".
|
||||
func formatSpan(secs float64) string {
|
||||
n := int(math.Ceil(secs))
|
||||
if n < 3600 {
|
||||
return formatCycle(float64(n))
|
||||
}
|
||||
h, m := n/3600, n%3600/60
|
||||
if m == 0 {
|
||||
return fmt.Sprintf("%dh", h)
|
||||
}
|
||||
return fmt.Sprintf("%dh %dm", h, m)
|
||||
}
|
||||
|
||||
// wellsText is "1 well", "2 wells" or "whole moon".
|
||||
func wellsText(reach int) string {
|
||||
switch {
|
||||
@@ -1173,12 +996,7 @@ func (s *State) project(m *Moon) projection {
|
||||
n := len(m.Wells)
|
||||
p := projection{Take: map[string][]float64{}, Gets: map[string]float64{}, Demand: make([]float64, n),
|
||||
Remain: m.supply(), Claims: make([]int, n), DryAfter: make([]int, n), DryAt: make([]time.Time, n)}
|
||||
var stakes []Stake
|
||||
for _, st := range s.Stakes {
|
||||
if st.MoonID == m.ID {
|
||||
stakes = append(stakes, st)
|
||||
}
|
||||
}
|
||||
stakes := slices.Clone(s.stakesOn(m.ID))
|
||||
sort.SliceStable(stakes, func(i, j int) bool { return stakes[i].Start.Before(stakes[j].Start) })
|
||||
current := m.supply()
|
||||
last := make([]time.Time, n) // latest end among the drills that drew from each well so far
|
||||
@@ -1306,38 +1124,8 @@ func (r WellRow) NoteText() string {
|
||||
return t + " · " + formatMoney(r.Remain) + " free after"
|
||||
}
|
||||
|
||||
// BarStack is a wells table row's timer bars (template "drill-stack"): the first drills,
|
||||
// oldest first, and how many it leaves out. Key makes the bars' ids unique on the page
|
||||
// (the morph matches them per drill).
|
||||
type BarStack struct {
|
||||
Key string
|
||||
Bars []StakeRow
|
||||
More int
|
||||
}
|
||||
|
||||
// Stack is the well's timer bars; phone for the copy shown under the supply bar on phones.
|
||||
func (r WellRow) Stack(phone bool) BarStack { return barStack("w"+r.ID, r.Queue(), phone) }
|
||||
|
||||
// Stack is the multi-well drills' timer bars.
|
||||
func (g DrillGroup) Stack(phone bool) BarStack { return barStack("m"+g.MoonID, g.Drills, phone) }
|
||||
|
||||
func barStack(key string, q []StakeRow, phone bool) BarStack {
|
||||
if phone {
|
||||
key = "p" + key
|
||||
}
|
||||
return BarStack{Key: key, Bars: q[:min(len(q), maxBars)], More: max(0, len(q)-maxBars)}
|
||||
}
|
||||
|
||||
const maxBars = 4
|
||||
|
||||
// Pct is how far through its cycle the drill is (0-100).
|
||||
func (r StakeRow) Pct() float64 {
|
||||
if r.Ready || r.DurationInSecs <= 0 {
|
||||
return 100
|
||||
}
|
||||
return min(100, max(0, 100*(1-r.Remaining.Seconds()/r.DurationInSecs)))
|
||||
}
|
||||
|
||||
// Code is the tool's model code, the last word of its name ("PD-1").
|
||||
func (r StakeRow) Code() string {
|
||||
if r.Item == nil {
|
||||
@@ -1381,23 +1169,9 @@ func (r StakeRow) Fate() string {
|
||||
return s
|
||||
}
|
||||
|
||||
// DrillsTitle is the tooltip of a row's timer bars: each drill and what it gets.
|
||||
func (r WellRow) DrillsTitle() string { return drillsTitle(r.Queue()) }
|
||||
func (g DrillGroup) DrillsTitle() string { return drillsTitle(g.Drills) }
|
||||
|
||||
// DrillLabel names a row's own drills compactly ("PD-1 Y1 C2", "PD-1 ×3", "PD-1 ×3 +1");
|
||||
// a well's multi-well drills show only as diamond-capped bars.
|
||||
func (r WellRow) DrillLabel() string { return drillLabel(r.Drills) }
|
||||
// DrillLabel names the group's drills compactly ("PD-1 Y1 C2", "PD-1 ×3", "PD-1 ×3 +1").
|
||||
func (g DrillGroup) DrillLabel() string { return drillLabel(g.Drills) }
|
||||
|
||||
func drillsTitle(q []StakeRow) string {
|
||||
lines := make([]string, len(q))
|
||||
for i, d := range q {
|
||||
lines[i] = d.Fate()
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func drillLabel(q []StakeRow) string {
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
@@ -1460,31 +1234,6 @@ func (s *State) betterWell(skip string, free float64) *BetterWell {
|
||||
return best
|
||||
}
|
||||
|
||||
// URL opens the well.
|
||||
func (b *BetterWell) URL() string {
|
||||
return View{Section: "moons", Moon: b.MoonID, Well: b.WellID}.URL()
|
||||
}
|
||||
|
||||
// Queue is every drill on the well, in the order the projection claims them (oldest first).
|
||||
func (r WellRow) Queue() []StakeRow {
|
||||
q := append(append([]StakeRow{}, r.Drills...), r.Wide...)
|
||||
sort.SliceStable(q, func(i, j int) bool { return q[i].Start.Before(q[j].Start) })
|
||||
return q
|
||||
}
|
||||
|
||||
// SupplyBar is a well's supply bar with its template's settings (template "well-supply").
|
||||
type SupplyBar struct {
|
||||
W WellRow
|
||||
Max float64
|
||||
Now time.Time
|
||||
Panel bool
|
||||
}
|
||||
|
||||
// Bar is the well's supply bar for the wells table or (panel) the side panel.
|
||||
func (r WellRow) Bar(max float64, now time.Time, panel bool) SupplyBar {
|
||||
return SupplyBar{W: r, Max: max, Now: now, Panel: panel}
|
||||
}
|
||||
|
||||
// secsText is formatCycle for a template value, int or float64 seconds.
|
||||
func secsText(v any) string {
|
||||
switch n := v.(type) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type buyWell struct{ supply, busy float64 }
|
||||
|
||||
// buyTestState is a new player with one moon of single-resource wells (supplies in units of
|
||||
// the new tool's yield; busy: what a drill already on the well takes, 0 for none) and a new
|
||||
// single-well tool.
|
||||
func buyTestState(t *testing.T, wells []buyWell) (*State, string, float64) {
|
||||
s := newPlayerState()
|
||||
var item *StoreItem
|
||||
for i := range s.Config.StoreItems {
|
||||
if it := &s.Config.StoreItems[i]; it.ToolReach() == 1 {
|
||||
item = it
|
||||
break
|
||||
}
|
||||
}
|
||||
if item == nil {
|
||||
t.Skip("no single-well tool in the config")
|
||||
}
|
||||
tool := Tool{ID: testID("t", 500), StoreItemID: item.ID}
|
||||
s.Tools = []Tool{tool}
|
||||
_, yield := s.drill(tool, item, "")
|
||||
res := s.Config.Resources[0]
|
||||
moon := Moon{ID: testID("m", 1), Name: "One", CreatedAt: s.Now.Add(-time.Hour)}
|
||||
for i, w := range wells {
|
||||
well := Well{ID: testID("w", i), Resource: res, Supply: w.supply * yield}
|
||||
moon.Wells = append(moon.Wells, well)
|
||||
if w.busy > 0 {
|
||||
other := Tool{ID: testID("t", 600+i), StoreItemID: item.ID}
|
||||
s.Tools = append(s.Tools, other)
|
||||
s.Stakes = append(s.Stakes, Stake{ID: testID("s", i), MoonID: moon.ID, WellID: well.ID, ToolID: other.ID,
|
||||
Resource: res, Amount: w.busy * yield, Start: s.Now, DurationInSecs: 60, Reach: 1})
|
||||
}
|
||||
}
|
||||
s.Moons = []Moon{moon}
|
||||
return s, tool.ID, yield
|
||||
}
|
||||
|
||||
// A well the drills already on it will empty is not suggested (stake would refuse it).
|
||||
func TestBuySuggestionsSkipCommittedWells(t *testing.T) {
|
||||
s, id, _ := buyTestState(t, []buyWell{{1, 1}})
|
||||
m := s.buySuggestions(id)
|
||||
if len(m.Suggestions) != 0 || !m.AllEmpty {
|
||||
t.Fatalf("suggestions %+v (allEmpty %v), want none: the well is fully committed", m.Suggestions, m.AllEmpty)
|
||||
}
|
||||
// With room for half a cycle, it is suggested, worth half a cycle.
|
||||
s, id, _ = buyTestState(t, []buyWell{{1.5, 1}})
|
||||
m = s.buySuggestions(id)
|
||||
if len(m.Suggestions) != 1 {
|
||||
t.Fatalf("suggestions %+v, want the well", m.Suggestions)
|
||||
}
|
||||
full := s.sellPrice(s.Moons[0].Wells[0].Resource) * s.Moons[0].Wells[0].Supply / 1.5
|
||||
if got := m.Suggestions[0].Credits; got < full/2-1e-6 || got > full/2+1e-6 {
|
||||
t.Fatalf("credits %v, want %v (half a cycle left)", got, full/2)
|
||||
}
|
||||
}
|
||||
|
||||
// "Highest value" goes to the best well shown, even when it is not first or last.
|
||||
func TestBuySuggestionsHighestValue(t *testing.T) {
|
||||
// A: idle, 3/4 of a cycle left; B: busy, plenty; C: busy, 3/4 of a cycle in the well,
|
||||
// half after its drill.
|
||||
s, id, _ := buyTestState(t, []buyWell{{0.75, 0}, {10, 1}, {0.75, 0.25}})
|
||||
m := s.buySuggestions(id)
|
||||
if len(m.Suggestions) != 3 {
|
||||
t.Fatalf("suggestions %+v, want 3", m.Suggestions)
|
||||
}
|
||||
if m.Suggestions[1].ID != s.Moons[0].Wells[1].ID || m.Suggestions[1].Reason != "Highest value" {
|
||||
t.Fatalf("second suggestion %+v, want well B with Highest value", m.Suggestions[1])
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
// A tool's panel opens as soon as Equipment is unlocked; its Store parts (upgrades,
|
||||
// selling) show only once the Store is.
|
||||
func TestViewDrillPanelOnboarding(t *testing.T) {
|
||||
tmpl, err := (&server{}).parseTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
flags []string
|
||||
open, store bool
|
||||
@@ -37,20 +29,6 @@ func TestViewDrillPanelOnboarding(t *testing.T) {
|
||||
p := s.page(v)
|
||||
if p.Drill == nil || p.Drill.Store != c.store {
|
||||
t.Errorf("%v: panel %+v, want store %v", c.flags, p.Drill, c.store)
|
||||
continue
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&b, "drill-panel", p.Drill); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
html := b.String()
|
||||
if strings.Count(html, "track-ladder") != 2 {
|
||||
t.Errorf("%v: no track ladders", c.flags)
|
||||
}
|
||||
for _, part := range []string{"/act/sell-tool", "/act/upgrade"} {
|
||||
if strings.Contains(html, part) != c.store {
|
||||
t.Errorf("%v: %q shown %v, want %v", c.flags, part, !c.store, c.store)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user