Compare commits

...
25 Commits
Author SHA1 Message Date
JosephFerano 64e1d8c0f4 Fix endpoint issue 2026-10-27 17:57:58 +07:00
JosephFeranoandClaude Opus 5.5 de05c7215f Merge opt-rebuild: cheap signatures, Moons panel in kept parts, fewer rebuilds on the server's answer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:28:03 +07:00
JosephFeranoandClaude Opus 5.5 864f357fd2 Aside facts are their own block on Moons, the moon list's sig is what its rows show, drills ending on our clock don't rebuild banner, header or moon list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:27:15 +07:00
JosephFeranoandClaude Opus 5.5 986012a80d Moons: heading, well cards, drill and deploy slots, the multi-well card and tray tiles are kept parts; a deploy rebuilds only what it changed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:27:15 +07:00
JosephFeranoandClaude Opus 5.5 4f0f2f13e3 blocks.sig writes a frozen state's parts as content tokens, once per state; kept blocks renew their Druid uids so a kept button keeps input priority
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:27:15 +07:00
JosephFeranoandClaude Opus 5.5 1c5d1f372a Merge opt-market: market series cached per tick, config cached, moon descriptions only where sent
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:22:51 +07:00
JosephFeranoandClaude Opus 5.5 9ad12796e1 Merge opt-state: indexes (migration 022), index-friendly state queries, grouped stakes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:03:58 +07:00
JosephFeranoandClaude Opus 5.5 7980b7bc23 State loads: indexes for the per-player reads (migration 022), uuid[] past-moon queries that use them, last well per tool, wells and stakes grouped once per request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:03:12 +07:00
JosephFeranoandClaude Opus 5.5 95d3f6e2ce Merge opt-claim: claim-all in a fixed number of statements, proven equal by a golden test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:56:51 +07:00
JosephFeranoandClaude Opus 5.5 bfcf2a8d9a Claim-all timing test: a crowded case, 900 drills on 25 moons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:56:20 +07:00
JosephFeranoandClaude Opus 5.5 b5373c8c62 Merge opt-equip: Equipment as a long list of kept rows; ladders sent once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:56:10 +07:00
JosephFeranoandClaude Opus 5.5 3f1e706741 Test the market series cache against computed rates and a config change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:55:16 +07:00
JosephFeranoandClaude Opus 5.5 20278f588f wt: a worktree's .env names its own database (mm_<task>); AGENTS.md: never git stash in a worktree (the stash list is shared)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:54:48 +07:00
JosephFeranoandClaude Opus 5.5 38a7140f26 AGENTS.md: the claim-all database test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:50:07 +07:00
JosephFeranoandClaude Opus 5.5 445436f943 Equipment rows are a long list (only the rows in view exist) and each row is kept while what it shows stays the same
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:50:00 +07:00
JosephFeranoandClaude Opus 5.5 e140bc30fc Claim-all in a few statements: lock and read the moons once, play the claims and redeploys through in memory, write each table with one statement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:49:19 +07:00
JosephFeranoandClaude Opus 5.5 4446cad274 Claim-all database test: golden scenarios of the current claim rules and a timing test (MM_TEST_DATABASE)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:42:45 +07:00
JosephFeranoandClaude Opus 5.5 681927fc23 Equipment sends each upgrade ladder once per store item and levels (equipment.ladders, rows name theirs by ladderKey)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:39:49 +07:00
JosephFeranoandClaude Opus 5.5 ff656930c8 Moon descriptions: loadState leaves them out; the moon view loads the selected moon's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:36:41 +07:00
JosephFeranoandClaude Opus 5.5 78f611fd8c loadConfig: cache resources and store items per config snapshot (re-read every 30 s for other processes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:36:33 +07:00
JosephFeranoandClaude Opus 5.5 0dbe349da5 Market: cache each item's 24h series per tick and config snapshot, shared by all requests; drop the unsent SVG chart and sparkline paths
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:36:19 +07:00
JosephFeranoandClaude Opus 5.5 429199f462 Client builds run bob.jar with the Defold editor's own Java (found on Linux, macOS and Windows; DEFOLD, JAVA override), else java from PATH
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:51:52 +07:00
JosephFeranoandClaude Opus 5.5 b8f877fb45 Admin: plain look, no motion: no entrance or tab-slide scripts, no dialog animations, no glows, gradients, blur or corner brackets, system fonts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:07:43 +07:00
JosephFeranoandClaude Opus 5.5 ec6edefd24 READMEs: the browser game at :3000 (served by the server), admin at /utils, MM_WEB_DIR, migrate after config edits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:05:17 +07:00
JosephFeranoandClaude Opus 5.5 dac62506b6 The game server serves the web build at / (client/dist/web/MoonMiners, MM_WEB_DIR); the admin stays at /utils
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:04:44 +07:00
41 changed files with 7443 additions and 886 deletions

No files matched your search

+5
View File
@@ -36,6 +36,11 @@ banned players left out).
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>` port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>` rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`. also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
- A worktree's `.env` names its own database (`DB_DATABASE=mm_<task>`), so `migrate`,
tests and servers run there reach only `mm_<task>`. Never point anything at the main
database (`moonminers`): it holds the author's game.
- Never use `git stash` in a worktree: the stash list is shared by every worktree, so a
pop can take another agent's work. Commit on your branch, or save a patch file.
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or - `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
commit it. commit it.
+15 -8
View File
@@ -14,25 +14,32 @@ Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
podman-compose up -d db # or: docker compose up -d db podman-compose up -d db # or: docker compose up -d db
cd server cd server
make migrate make migrate
make dev # admin at http://localhost:3000/utils make dev # http://localhost:3000: the game (once built, below); /utils: the admin
``` ```
After pulling changes, run `make migrate` again before starting the server: it applies new After pulling changes or editing `server/config/game.json`, run `make migrate` again before
migrations and syncs the tools with `config/game.json` (the server alone doesn't). starting the server: it applies new migrations and syncs the tools and prices with the
config (the server alone doesn't).
## Defold client ## Defold client
Needs Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor Needs the Defold editor (https://defold.com/download/) or Java 21+. The Makefile downloads
is optional: https://defold.com/download/ Defold's command-line builder (`bob.jar`) and runs it with the Java the editor ships, found
in the usual places on Linux, macOS and Windows (`make DEFOLD=<folder holding its packages>`
With the server running: for another place, `JAVA=<path>` for your own Java); without an editor, `java` from PATH.
```bash ```bash
cd client cd client
make run # desktop build, plays against http://localhost:3000 make run # desktop build, plays against http://localhost:3000
make web && make serve-web # browser build: open http://localhost:8080 make web # browser build into dist/web/MoonMiners/
``` ```
The server serves the browser build at http://localhost:3000 (from
`client/dist/web/MoonMiners`, or `MM_WEB_DIR`). It looks for the build when it starts:
after the first `make web`, restart the server; later builds only need a page reload.
Without a build, `/` goes to the admin. The desktop and browser games keep separate saved
players.
## Deploy ## Deploy
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`. Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
+5 -1
View File
@@ -42,7 +42,11 @@ map.
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW; `gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
layout is top-left based (x right, y down). layout is top-left based (x right, y down).
- Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua; - Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua;
`ASIDE_KEYS`, header and moon list sigs likewise) or it won't update. What must survive `ASIDE_KEYS` and the header's sig likewise; the moon list's rows: `moonlist.rows_sig`;
the moon's facts in the aside: `aside.facts_sig`; the Moons parts: their models in
moons.lua) or it won't update. The shown state is frozen (`blocks.freeze`: `blocks.sig`
writes each part once per state, by content): never change `g.state` in place; a
prediction changes its copy before it is shown. What must survive
a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across
builds outside its block. A new per-build registry notes its entries in the block journal builds outside its block. A new per-build registry notes its entries in the block journal
(`blocks.note`/`push`/`put`); code that builds outside a build runs through (`blocks.note`/`push`/`put`); code that builds outside a build runs through
+24 -11
View File
@@ -1,5 +1,17 @@
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool, # Moon Miners Defold client. Builds with bob.jar, run by the Java the Defold editor ships
# python3 and luajit for shot, play, drive, dev and check. # (its packages/jdk-*), so the editor is all a build needs. The editor is looked for where it
# usually is on Linux (~/Defold, /opt/Defold), macOS (Defold.app in /Applications or
# ~/Applications) and Windows (~/Defold, C:/Defold, Git Bash or MSYS make); DEFOLD=<the folder
# holding its packages> for another place, JAVA=<path> for a Java of your own. With no editor
# found it is java (21+) from PATH. xvfb-run, Xvfb, ImageMagick, xdotool, python3 and luajit
# for shot, play, drive, dev and check (Linux).
DEFOLD_DIRS := $(DEFOLD) $(HOME)/Defold /opt/Defold \
/Applications/Defold.app/Contents/Resources $(HOME)/Applications/Defold.app/Contents/Resources \
$(USERPROFILE)/Defold C:/Defold
# (make's own wildcard and sort, no shell tools; the last after sorting is the newest)
DEFOLD_JAVA := $(lastword $(sort $(foreach d,$(DEFOLD_DIRS),$(wildcard \
$(d)/packages/jdk*/bin/java $(d)/packages/jdk*/bin/java.exe $(d)/packages/jdk*/Contents/Home/bin/java))))
JAVA ?= $(or $(DEFOLD_JAVA),java)
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
BOB := tools/bob.jar BOB := tools/bob.jar
@@ -18,25 +30,26 @@ $(BOB):
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@ curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
resolve: $(BOB) resolve: $(BOB)
java -jar $(BOB) --platform $(PLATFORM) resolve $(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
# Dependencies are fetched again only when game.project changes (`make resolve` forces it). # Dependencies are fetched again only when game.project changes (`make resolve` forces it).
.internal/lib/dependencies.json: game.project | $(BOB) .internal/lib/dependencies.json: game.project | $(BOB)
java -jar $(BOB) --platform $(PLATFORM) resolve $(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
touch $@ touch $@
build: $(BOB) .internal/lib/dependencies.json build: $(BOB) .internal/lib/dependencies.json
java -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \ $(JAVA) -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
--archive --bundle-output dist/$(PLATFORM) build bundle --archive --bundle-output dist/$(PLATFORM) build bundle
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release # Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release
# by default; WEB_VARIANT=debug keeps logs and the test hook (window.mmTestQueue). # by default; WEB_VARIANT=debug keeps logs and the test hook (window.mmTestQueue).
# `make serve-web` serves it on :8080 with /api and /static proxied to UPSTREAM. # The game server serves it at / (server.go, MM_WEB_DIR); `make serve-web` serves it on :8080
# with /api and /static proxied to UPSTREAM (a test server).
WEB_VARIANT ?= release WEB_VARIANT ?= release
UPSTREAM ?= http://localhost:3000 UPSTREAM ?= http://localhost:3000
WEB_PORT ?= 8080 WEB_PORT ?= 8080
web: $(BOB) .internal/lib/dependencies.json web: $(BOB) .internal/lib/dependencies.json
java -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \ $(JAVA) -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
--archive --bundle-output dist/web build bundle --archive --bundle-output dist/web build bundle
serve-web: serve-web:
@@ -50,7 +63,7 @@ dev: build
reload: $(BOB) reload: $(BOB)
@mkdir -p dist/dev @mkdir -p dist/dev
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \ @$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; } || { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
@scripts/dev.py reload @scripts/dev.py reload
@@ -63,7 +76,7 @@ SCENARIO ?= smoke
DRIVE ?= --fixture DRIVE ?= --fixture
drive: $(BOB) .internal/lib/dependencies.json drive: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist @mkdir -p dist
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \ @$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; } || { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
scripts/drive.py $(DRIVE) $(SCENARIO) scripts/drive.py $(DRIVE) $(SCENARIO)
@@ -71,7 +84,7 @@ drive: $(BOB) .internal/lib/dependencies.json
# dist/drive/lint-<size>/. Needs MM_API (a test server). # dist/drive/lint-<size>/. Needs MM_API (a test server).
lint: $(BOB) .internal/lib/dependencies.json lint: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist @mkdir -p dist
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \ @$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; } || { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
@for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done @for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done
@@ -79,7 +92,7 @@ lint: $(BOB) .internal/lib/dependencies.json
# scenario run (sections) against MM_API (required: a test server) if it answers. # scenario run (sections) against MM_API (required: a test server) if it answers.
check: $(BOB) .internal/lib/dependencies.json check: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist @mkdir -p dist
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \ @$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; } || { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
scripts/check.py scripts/check.py
+16 -5
View File
@@ -6,9 +6,12 @@
-- local l = list.new(g, parent, x, y, w, h, { -- local l = list.new(g, parent, x, y, w, h, {
-- key = "moons", row_h = 64, -- the row's slot (gap included) -- key = "moons", row_h = 64, -- the row's slot (gap included)
-- create = function(item, index) ... return root[, druid component] end, -- create = function(item, index) ... return root[, druid component] end,
-- focus_id = function(item) return "moon:" .. item.id end }) -- (optional) the test -- focus_id = function(item) return "moon:" .. item.id end, -- (optional) the test
-- -- id its row registers: Tab walks -- -- id its row registers: Tab walks
-- -- every row, scrolling (modules/focus) -- -- every row, scrolling (modules/focus)
-- on_remove = function(node, item) ... end }) -- (optional) a row scrolled out, before
-- -- its node is deleted (not when the
-- -- list itself goes in a rebuild)
-- l:set_data(items) -- (again with a filtered list: the rows are made again) -- l:set_data(items) -- (again with a filtered list: the rows are made again)
-- --
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid -- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
@@ -43,6 +46,13 @@ function M.new(g, parent, x, y, w, h, opts)
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index) l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
return l.create(item, index) return l.create(item, index)
end) end)
if opts.on_remove then
l.data.on_element_remove:subscribe(function(_, _, node, _, item)
if not g.tearing_down then
opts.on_remove(node, item)
end
end)
end
l.scroll.on_scroll:subscribe(function(_, pos) l.scroll.on_scroll:subscribe(function(_, pos)
if g.tearing_down then if g.tearing_down then
return -- (the old list emptying itself during a rebuild: keep the offset) return -- (the old list emptying itself during a rebuild: keep the offset)
@@ -55,15 +65,16 @@ function M.new(g, parent, x, y, w, h, opts)
end end
function M:set_data(items) function M:set_data(items)
local saved = self.g.scroll_pos[self.key] or 0 -- (setting the data scrolls to the top) local saved = self.g.scroll_pos[self.key] or 0
self.items = items self.items = items
self.data:set_data(items) -- At the saved offset first (Druid clamps it to the rows), so the data list makes the
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the -- rows there, not the top ones only to drop them again.
-- scroll event).
local s = self.scroll local s = self.scroll
s:set_size(self.grid:get_size_for(#items))
s.target_position.y = saved s.target_position.y = saved
s:_set_scroll_position(s.position.x, saved) s:_set_scroll_position(s.position.x, saved)
s.target_position.y = s.position.y s.target_position.y = s.position.y
self.data:set_data(items)
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h) local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
for _, a in ipairs(self.g.scroll_areas) do for _, a in ipairs(self.g.scroll_areas) do
if a.list == self then if a.list == self then
+97 -4
View File
@@ -36,9 +36,50 @@ end
---------------------------------------------------------------- signatures ---------------------------------------------------------------- signatures
local function ser(v, buf) -- Frozen tables (M.freeze: a state's parts, which nothing changes once shown) are written
-- as a token of their content, worked out once per table: two tables with the same
-- content get the same token, so a block whose inputs the server's answer left as the
-- prediction had them is kept, and a list as long as the moons is serialized once per
-- state, not once per block and build. (A frozen table must not change.)
local frozen = setmetatable({}, { __mode = "k" }) -- table -> true (not worked out yet) or its token
local tokens, token_bytes, token_n = {}, 0, 0 -- content -> token; the content strings' size
local TOKEN_BYTES = 4 * 1024 * 1024 -- (then forgotten: equal content gets a new token once)
local ser
local function token_of(v)
local tok = frozen[v]
if tok ~= true then
return tok
end
local buf = {}
frozen[v] = false -- (while it's written: a table inside itself is written out)
ser(v, buf, true)
local s = table.concat(buf)
tok = tokens[s]
if not tok then
if token_bytes > TOKEN_BYTES then
tokens, token_bytes = {}, 0
end
token_n = token_n + 1
tok = "#" .. token_n
tokens[s], token_bytes = tok, token_bytes + #s
end
frozen[v] = tok
return tok
end
local function key_lt(a, b)
return tostring(a) < tostring(b)
end
ser = function(v, buf, own)
local t = type(v) local t = type(v)
if t == "table" then if t == "table" then
if not own and frozen[v] then
buf[#buf + 1] = token_of(v)
return
end
buf[#buf + 1] = "{" buf[#buf + 1] = "{"
local n = #v local n = #v
for i = 1, n do for i = 1, n do
@@ -55,7 +96,7 @@ local function ser(v, buf)
end end
if keys then if keys then
if mixed then if mixed then
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) table.sort(keys, key_lt)
else else
table.sort(keys) table.sort(keys)
end end
@@ -68,7 +109,9 @@ local function ser(v, buf)
end end
buf[#buf + 1] = "}" buf[#buf + 1] = "}"
elseif t == "string" then elseif t == "string" then
buf[#buf + 1] = #v .. ":" .. v buf[#buf + 1] = #v
buf[#buf + 1] = ":"
buf[#buf + 1] = v
elseif t == "function" then elseif t == "function" then
buf[#buf + 1] = "f" buf[#buf + 1] = "f"
else else
@@ -76,7 +119,8 @@ local function ser(v, buf)
end end
end end
-- A string that is the same exactly when the values are (tables by content, keys sorted). -- A string that is the same exactly when the values are (tables by content, keys sorted;
-- frozen ones by their token, the same for the same content).
function M.sig(...) function M.sig(...)
local buf = {} local buf = {}
for i = 1, select("#", ...) do for i = 1, select("#", ...) do
@@ -86,6 +130,23 @@ function M.sig(...)
return table.concat(buf) return table.concat(buf)
end end
-- Freeze the tables in t (a state: its lists and records, one level down; t itself too)
-- for M.sig: nothing may change them afterwards (a prediction changes its copy before).
function M.freeze(t)
if type(t) ~= "table" then
return t
end
if frozen[t] == nil then
frozen[t] = true
end
for _, v in pairs(t) do
if type(v) == "table" and frozen[v] == nil then
frozen[v] = true
end
end
return t
end
---------------------------------------------------------------- registrations ---------------------------------------------------------------- registrations
-- Note a registration made while building (fn(...) makes it again in a later build that -- Note a registration made while building (fn(...) makes it again in a later build that
@@ -216,6 +277,37 @@ local function rezone(b)
end end
end end
-- After a kept block is taken: its Druid components (and those of the blocks inside it)
-- get new uids, in their order, as if made now. Druid hands input to the newest first
-- (by uid: druid_instance's sort_input_comparator), so a kept button inside a box rebuilt
-- around it (a slot in a well card, which selects the well when clicked) still gets its
-- clicks first, as a rebuilt one would.
local new_uid = require("druid.component").create_uid
local function renew(c)
local cc = c._component
if cc and cc._uid then
cc._uid = new_uid()
cc._is_input_priority_changed = true -- (the instance sorts its input stack again)
end
local kids = c.get_childrens and c:get_childrens() or {}
for i = 1, #kids do -- (already the whole subtree, in its order)
local k = kids[i]._component
if k and k._uid then
k._uid = new_uid()
end
end
end
local function reorder(b)
for _, c in ipairs(b.comps) do
pcall(renew, c)
end
for _, e in ipairs(b.journal) do
if e.block and not e.block.dead then
reorder(e.block)
end
end
end
-- True while a kept block's registrations are made again (test.register looks for its -- True while a kept block's registrations are made again (test.register looks for its
-- clip zone again then: the one it was given may have gone with the old parent). -- clip zone again then: the one it was given may have gone with the old parent).
function M.replaying() function M.replaying()
@@ -318,6 +410,7 @@ function M.open(g, key, sig, parent)
reg.used[key] = true reg.used[key] = true
gui.set_parent(old.root, parent, false) gui.set_parent(old.root, parent, false)
rezone(old) rezone(old)
reorder(old)
adopt(reg, old) adopt(reg, old)
return old, true return old, true
end end
+11 -2
View File
@@ -247,12 +247,16 @@ end
-- Rows of a long list exist only while in view, so Tab steps through its items instead: -- Rows of a long list exist only while in view, so Tab steps through its items instead:
-- the item before / after the focused row (or the first / last when coming from outside), -- the item before / after the focused row (or the first / last when coming from outside),
-- scrolled into view; the rebuild makes its row, which then has the focus. Returns true -- scrolled into view; the rebuild makes its row, which then has the focus. Within the rows
-- when it moved the focus. -- in view Tab goes in order (a row's own buttons too); from a button inside a row it steps
-- from that row (the one under it). Returns true when it moved the focus.
local function list_step(g, f, cur, e, dir, scope_) local function list_step(g, f, cur, e, dir, scope_)
local a = list_of(g, cur) local a = list_of(g, cur)
local items, i local items, i
if a then if a then
if list_of(g, e) == a then
return false -- (the next element is in view in the same list)
end
items = a.list.items items = a.list.items
local id = cur.base:sub(#scope_ + 2) local id = cur.base:sub(#scope_ + 2)
for n, item in ipairs(items) do for n, item in ipairs(items) do
@@ -260,6 +264,11 @@ local function list_step(g, f, cur, e, dir, scope_)
i = n + dir i = n + dir
end end
end end
if not i and a.list.row_h then
local r, v = M.rect(cur.node), M.rect(a.view)
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
i = math.floor((r.y + r.h / 2 - v.y + pos) / a.list.row_h) + 1 + dir
end
if not i or i < 1 or i > #items then if not i or i < 1 or i > #items then
return false -- (leaving the list: the next element on the page) return false -- (leaving the list: the next element on the page)
end end
+7 -1
View File
@@ -96,7 +96,13 @@ function M.register(id, node, kind, label, zone, no_focus)
if not M.enabled or not id then if not M.enabled or not id then
return return
end end
if elements[id] then local old = elements[id]
if old and not pcall(gui.get_id, old.node) then
-- (its node went: a long list's row scrolled out and back keeps its plain id)
old.node, old.kind, old.label, old.zone, old.scope = node, kind or "button", label, zone, scope
return id
end
if old then
local n = 2 local n = 2
while elements[id .. "#" .. n] do while elements[id .. "#" .. n] do
n = n + 1 n = n + 1
+80 -13
View File
@@ -22,6 +22,8 @@ local chip = require("components.chip")
local images = require("modules.images") local images = require("modules.images")
local flex = require("modules.flex") local flex = require("modules.flex")
local fg = require("modules.flex_gui") local fg = require("modules.flex_gui")
local blocks = require("modules.blocks")
local anim = require("modules.anim")
local S = theme.size local S = theme.size
@@ -422,10 +424,83 @@ local function caption(c, x, y, inner, title, text)
}), x, y), c) }), x, y), c)
end end
-- The facts under the viewport on the Moons section, built into root (at where.root's
-- origin; where: what M.build returned with the panel): the selected well's panel, the moon's status, or a mined-out caption. A
-- block of their own (core/main.lua), kept while M.facts_sig stays.
function M.facts(g, root, where, H)
local st = g.state
local moon = st.moon
local top, w, h, pad, inner = where.top, where.w, where.h, where.pad, where.inner
if st.well then
-- The well panel scrolls under the viewport (many drills).
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
elseif moon.finished then
caption(root, pad, where.cy, inner, moon.name, "Mined out. Nothing left to dig.")
else
-- (scrolls like the well panel: Sell moon at its foot)
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
end
end
-- What M.facts shows (its block's signature, with where): from the state and our clock.
function M.facts_sig(g, where, H)
local st = g.state
local moon = st.moon
local now = H.now(g)
local sale = st.unlocked.store and moon.sale or false
local hold = sale and sale.holdEnd and sale.holdEnd > now and math.floor((sale.holdEnd - now) / 1000) or false
local common = { where.top, where.cy, where.w, where.h, where.inner, moon.id, moon.name, moon.finished or false,
sale, hold, g.sale_open and g.sale_open[moon.id] or false, g.ask and g.ask.id or false, st.catalog or false, st.catalogVersion or false }
if moon.finished then
return blocks.sig(common)
end
local supplies = {}
for i, x in ipairs(moon.wells or {}) do
supplies[i] = x.supply
end
if st.well then
local w = st.well
local wrow = find(moon.wells, w.id) or false
local tools = {}
for i, d in ipairs(wrow and wrow.drills or {}) do
local t = find(st.tools, d.toolId)
tools[i] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
end
local wide = wrow and #(wrow.wide or {}) > 0 and moon.wide or false
for _, d in ipairs(wide and wide.drills or {}) do
local t = find(st.tools, d.toolId)
tools[#tools + 1] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
end
local better = w.better or false
local bt = better and find(st.tools, better.toolId)
local bm = better and find(st.moons, better.moonId)
return blocks.sig(common, w.id, w.resource, w.rate, better, bt and { bt.name or false, ui.levels_tag(bt) } or false,
bm and bm.name or false, wrow, wide, wide and moon.wells or false, tools, st.bonuses or false, moon.maxSupply or false,
st.rules and st.rules.maxSupply or false, moons.supply_max(st, moon), anim.reduced)
end
local mining, done, idle = 0, 0, 0
for _, s in ipairs(st.stakes or {}) do
if s.moonId == moon.id then
if s.ready or (s["end"] or math.huge) <= now then
done = done + 1
else
mining = mining + 1
end
end
end
for _, t in ipairs(st.tools or {}) do
idle = idle + (t.stakeId and 0 or 1)
end
return blocks.sig(common, supplies, mining, done, idle)
end
-- Region contract (AGENTS.md, "Game screen"): builds the aside panel at (x, y), w x h. -- Region contract (AGENTS.md, "Game screen"): builds the aside panel at (x, y), w x h.
-- mod: the current section's module (its aside / aside_below, if any). row: the narrow -- mod: the current section's module (its aside / aside_below, if any). row: the narrow
-- layout's row (core/main.lua): the viewport on the left, the side panel beside it. -- layout's row (core/main.lua): the viewport on the left, the side panel beside it.
-- Returns the panel (components/panel: its entrance). -- Returns the panel (components/panel: its entrance) and, on the Moons section with a
-- moon, where its facts go (M.facts).
function M.build(g, parent, x, y, w, h, H, mod, row) function M.build(g, parent, x, y, w, h, H, mod, row)
local st = g.state local st = g.state
local p = panel.new(parent, x, y, w, h) local p = panel.new(parent, x, y, w, h)
@@ -474,18 +549,10 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
end end
gui.delete_node(box) gui.delete_node(box)
end end
if moon and st.well then if moon then
-- The well panel scrolls under the viewport (many drills). -- The moon's facts are a block of their own (M.facts, core/main.lua): a change the
local top = cy - 8 -- viewport doesn't show keeps the panel and its moon.
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id) return p, { root = root, top = cy - 8, cy = cy, w = w, h = h, pad = pad, inner = inner }
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
elseif moon and moon.finished then
caption(root, pad, cy, inner, moon.name, "Mined out. Nothing left to dig.")
elseif moon then
-- (scrolls like the well panel: Sell moon at its foot)
local top = cy - 8
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
elseif mod and mod.aside_below and st[section] then elseif mod and mod.aside_below and st[section] then
mod.aside_below(g, root, pad, cy, inner, limit, H) mod.aside_below(g, root, pad, cy, inner, limit, H)
elseif section == "new-moon" then elseif section == "new-moon" then
+2 -2
View File
@@ -146,9 +146,9 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
if g.autoplay then if g.autoplay then
local p = type(params) == "function" and params() or params local p = type(params) == "function" and params() or params
g.autoplay:register(name, p, fire) g.autoplay:register(name, p, fire)
blocks.note(function() -- (a kept block registers again) blocks.note(function() -- (a kept block registers again; params made now)
if g.autoplay then if g.autoplay then
g.autoplay:register(name, p, fire) g.autoplay:register(name, type(params) == "function" and params() or params, fire)
end end
end) end)
end end
+25 -10
View File
@@ -283,14 +283,25 @@ local function build_main(g, parent, x, y, w, h)
end end
-- The aside's block (narrow: the row under the panels). -- The aside's block (narrow: the row under the panels).
local function build_aside(g, parent, x, y, w, h, row, common) local function build_aside(g, parent, x, y, w, h, row, common, still)
local st = g.state local st = g.state
local section = g.view.section local section = g.view.section
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open, -- (on Moons with a moon the panel only shows the viewport: its facts are a block of
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS)) -- their own, below)
return blocks.keep(g, "aside", sig, parent, function(holder) local moon_only = section == "moons" and st.moon ~= nil
local sig = blocks.sig(moon_only and still or common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
moon_only and blocks.sig(st.moon.id, st.view and st.view.aside, st.catalogVersion or st.catalog or false)
or pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
local p, facts = blocks.keep(g, "aside", sig, parent, function(holder)
return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row) return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
end) end)
if facts then
-- The moon's facts: a block of their own inside the panel (aside.facts).
blocks.keep(g, "aside:facts", aside.facts_sig(g, facts, g.h), facts.root, function(holder)
aside.facts(g, holder, facts, g.h)
end)
end
return p
end end
-- A full rebuild drops every node and component and builds them all again; otherwise the -- A full rebuild drops every node and component and builds them all again; otherwise the
@@ -360,11 +371,15 @@ function M.build(g)
g.layout_state = true g.layout_state = true
local st = g.state local st = g.state
local common = local_sig(g) local common = local_sig(g)
-- (without the drills and batches that ended on our clock: the banner, header and moon
-- list show those counts through their own signatures, and a drill ending mustn't
-- rebuild them)
local still = local_sig(g, {})
-- Announcements and live events above the header. -- Announcements and live events above the header.
local banner_h = blocks.keep(g, "banner", blocks.sig(common, W, st.banner), g.layout, function(holder) local banner_h = blocks.keep(g, "banner", blocks.sig(still, W, st.banner), g.layout, function(holder)
return banner.build(g, g.h, holder, W) return banner.build(g, g.h, holder, W)
end) end)
local header_h = blocks.keep(g, "header", blocks.sig(common, W, banner_h, g.view.section, st.credits, st.resources, local header_h = blocks.keep(g, "header", blocks.sig(still, W, banner_h, g.view.section, st.credits, st.resources,
st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder) st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
g.tab_nodes = {} g.tab_nodes = {}
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none") local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
@@ -438,8 +453,8 @@ function M.build(g)
for _, m in ipairs(st.moons) do for _, m in ipairs(st.moons) do
hides[#hides + 1] = g.reveal:hides(m.id) or false hides[#hides + 1] = g.reveal:hides(m.id) or false
end end
blocks.keep(g, "moonlist", blocks.sig(common, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon, blocks.keep(g, "moonlist", blocks.sig(still, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
st.moons, st.past, st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides, moonlist.rows_sig(g), st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder) st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder)
g.moon_nodes = {} g.moon_nodes = {}
moonlist.teardown(g) moonlist.teardown(g)
@@ -462,7 +477,7 @@ function M.build(g)
end end
end end
if page then if page then
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common) g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common, still)
local s = page:finish(y0 + body_h + gap + row_h + pad) local s = page:finish(y0 + body_h + gap + row_h + pad)
if g.viewport_node and not g.reveal:active() then if g.viewport_node and not g.reveal:active() then
moon_view.move(g.viewport_node) -- (where the page's offset put it) moon_view.move(g.viewport_node) -- (where the page's offset put it)
@@ -487,7 +502,7 @@ function M.build(g)
end) end)
end end
elseif has_aside then elseif has_aside then
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common) g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common, still)
else else
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
if not g.reveal:active() then -- (the reveal's window shows the moon) if not g.reveal:active() then -- (the reveal's window shows the moon)
+4 -2
View File
@@ -15,6 +15,7 @@ local predict = require("modules.predict")
local sfx = require("modules.sfx") local sfx = require("modules.sfx")
local refinery = require("screens.game.refinery") local refinery = require("screens.game.refinery")
local sidepanel = require("screens.game.sidepanel") local sidepanel = require("screens.game.sidepanel")
local blocks = require("modules.blocks")
local M = {} local M = {}
@@ -190,7 +191,8 @@ end
-- What we show: the last server state (g.base) with the pending predictions on top. -- What we show: the last server state (g.base) with the pending predictions on top.
local function show(g) local function show(g)
g.state = predict.apply(g.base, g.pending) -- (frozen: what is shown never changes, so blocks.sig writes each part once per state)
g.state = blocks.freeze(predict.apply(g.base, g.pending))
g.view = g.state.view g.view = g.state.view
arm_refetch(g) arm_refetch(g)
g.dirty = true g.dirty = true
@@ -460,7 +462,7 @@ function M.act(g, name, params, on_done, fn)
if predict.run(st, e) then if predict.run(st, e) then
g.pending[#g.pending + 1] = e g.pending[#g.pending + 1] = e
local prev = g.state local prev = g.state
g.state = st g.state = blocks.freeze(st)
g.dirty = true g.dirty = true
predicted = true predicted = true
-- The action's effects play now, from the predicted change (fx.lua). -- The action's effects play now, from the predicted change (fx.lua).
+69 -21
View File
@@ -12,6 +12,7 @@ local ui = require("modules.ui")
local tooltip = require("modules.tooltip") local tooltip = require("modules.tooltip")
local cards = require("screens.game.cards") local cards = require("screens.game.cards")
local scroll = require("components.scroll") local scroll = require("components.scroll")
local list = require("components.list")
local store = require("screens.game.store") local store = require("screens.game.store")
local row_c = require("components.row") local row_c = require("components.row")
local blocks = require("modules.blocks") local blocks = require("modules.blocks")
@@ -38,6 +39,13 @@ end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- A tool's track's ladder: state.equipment.ladders holds each once per store item and
-- levels, the row names its own (ladderKey).
local function ladder_of(st, t, tr)
local l = (st.equipment.ladders or {})[t.ladderKey or ""]
return l and l[tr.kind] or {}
end
-- Stats: Yield and Cycle (a stake's now), wrapping (rows 20 tall, 16 apart). A tree piece. -- Stats: Yield and Cycle (a stake's now), wrapping (rows 20 tall, 16 apart). A tree piece.
local function stats_box(st, parent, t) local function stats_box(st, parent, t)
local items = { { "Yield", ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) } } local items = { { "Yield", ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) } }
@@ -89,7 +97,7 @@ local function track_box(self, H, st, parent, t, tr, w)
-- this width), so the box (and the card) stays the same height from level to level. -- this width), so the box (and the card) stays the same height from level to level.
local parts_h = 0 local parts_h = 0
local probe = ui.rect(parent, 0, 0, 1, 1, "none") local probe = ui.rect(parent, 0, 0, 1, 1, "none")
for _, r in ipairs(tr.ladder or {}) do for _, r in ipairs(ladder_of(st, t, tr)) do
if #(r.parts or {}) > 0 then if #(r.parts or {}) > 0 then
parts_h = math.max(parts_h, cards.parts(st, probe, r.parts, 0, 0, w)) parts_h = math.max(parts_h, cards.parts(st, probe, r.parts, 0, 0, w))
end end
@@ -459,12 +467,34 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
end }) end })
end end
-- What a row shows besides its tool (t, with its tracks: their affordability is the
-- server's canAfford/shortText, so credits and parts aren't needed): kept while it's the same.
local function row_sig(self, st, t, copy, conf, inner, open)
local moon = t.stake and find(st.moons, "id", t.stake.moonId)
local busy = {} -- (its buttons waiting for the server: H.action's keys)
for k in pairs(self.inflight or {}) do
if k:find(t.id, 1, true) then
busy[#busy + 1] = k
end
end
table.sort(busy)
local sid = "confirm:sell-tool:" .. t.id
local ask = self.ask and self.ask.id == sid and self.ask.t or false
return blocks.sig(inner, open, t, copy, conf, moon and moon.name or false, (t.reach or 1) > 1 and cards.wells(st, t.reach) or false,
st.catalogVersion, self.view.drill ~= "", busy, ask)
end
-- The heading and the column captions stay at the top; the rows are a long list
-- (components/list) under them: only the rows in view exist (a screenful of nodes at any
-- number of tools), each a block kept while its row_sig stays the same, so an action
-- rebuilds only the rows it changed.
function M.build(self, root, px, py, w, h, H) function M.build(self, root, px, py, w, h, H)
local st = self.state local st = self.state
local eq = st.equipment local eq = st.equipment
local open = st.unlocked.store local open = st.unlocked.store
local area, x, inner, y = cards.page(self, root, px, py, w, h, "equipment", TABLE_MAX) local inner = math.min(w - px * 2, TABLE_MAX)
local c = area.content local x, y = math.floor((w - inner) / 2), py - 2
local c = ui.rect(root, 0, 2, w, h - 4, "none") -- (where cards.page's content was)
local geo = geometry(inner) local geo = geometry(inner)
y = fg.flow(c, x, y, inner, { col({ width = inner }, { -- (full width: Buy tools sits at its right edge) y = fg.flow(c, x, y, inner, { col({ width = inner }, { -- (full width: Buy tools sits at its right edge)
fg.label(c, "Equipment", "h1", 40), fg.label(c, "Equipment", "h1", 40),
@@ -479,26 +509,44 @@ function M.build(self, root, px, py, w, h, H)
#eq.tools > 0 and header(c, geo) or nil, #eq.tools > 0 and header(c, geo) or nil,
#eq.tools > 0 and space(0, 6) or nil, #eq.tools > 0 and space(0, 6) or nil,
}) }) }) })
if #eq.tools > 0 then if #eq.tools == 0 then
return
end
ui.rect(c, x, y, inner, 1, "line_hi") ui.rect(c, x, y, inner, 1, "line_hi")
y = y + 1 y = y + 1
end -- Each copy's number among tools of its type (in the table's order).
local seen, rh = {}, row_h(open, geo.wide) local seen, copies = {}, {}
for _, t in ipairs(eq.tools) do for _, t in ipairs(eq.tools) do
seen[t.storeItemId] = (seen[t.storeItemId] or 0) + 1 seen[t.storeItemId] = (seen[t.storeItemId] or 0) + 1
local copy = seen[t.storeItemId] copies[t.id] = seen[t.storeItemId]
local conf = sell_confirm(st, t)
local moon = t.stake and find(st.moons, "id", t.stake.moonId)
-- (kept while what it shows stays the same: modules/blocks)
local sig = blocks.sig(x, y, inner, t, copy, conf, open, moon and moon.name or false, st.refined, st.credits,
st.resources, st.catalogVersion, self.inflight, self.view, self.ask and self.ask.id or false)
local ry = y
blocks.keep(self, "eq:tool:" .. tostring(t.id), sig, c, function(r)
fg.build(flex.solve(tool_row(self, H, c, st, t, copy, geo, inner, open, conf), x, ry), r)
end)
y = y + rh
end end
area:finish(y + py) local rh = row_h(open, geo.wide)
local rows = {} -- slot node -> its row's block
local l
l = list.new(self, c, x, y, inner, math.max(rh, h - 4 - y - py), {
key = "equipment", row_h = rh,
focus_id = function(t) return "drill:" .. t.id end,
create = function(t)
local slot = ui.rect(l.content, 0, 0, inner, rh, "none")
local copy, conf = copies[t.id], sell_confirm(st, t)
local b, kept = blocks.open(self, "eq:tool:" .. tostring(t.id), row_sig(self, st, t, copy, conf, inner, open), slot)
if not kept then
fg.build(flex.solve(tool_row(self, H, b.root, st, t, copy, geo, inner, open, conf), 0, 0), b.root)
end
blocks.close(self, b)
rows[slot] = b
return slot
end,
-- A row scrolled out goes with its block (its buttons' Druid components too).
on_remove = function(slot)
local b = rows[slot]
rows[slot] = nil
if b then
blocks.drop(self, b)
end
end,
})
l:set_data(eq.tools)
end end
---------------------------------------------------------------- drill panel (aside) ---------------------------------------------------------------- drill panel (aside)
@@ -529,7 +577,7 @@ local TIER_HEAD = { font = "sans_semi", px = 10, color = "muted", tracking = 0.1
-- A track's ladder: a grid (Level, value, price and parts at 0, 16, 40 % of the row's -- A track's ladder: a grid (Level, value, price and parts at 0, 16, 40 % of the row's
-- inner width, ₥/h at the right), the current level and the next lit. -- inner width, ₥/h at the right), the current level and the next lit.
local function ladder(st, c, tr, inner) local function ladder(st, c, t, tr, inner)
local fr = { 0, 0.16, 0.4, 0.84 } local fr = { 0, 0.16, 0.4, 0.84 }
local cols = {} local cols = {}
for i = 1, 3 do for i = 1, 3 do
@@ -544,7 +592,7 @@ local function ladder(st, c, tr, inner)
-- (₥/h: its left edge 22 before the rate column's right edge) -- (₥/h: its left edge 22 before the rate column's right edge)
local rate_head = fg.label(c, theme.currency .. "/h", TIER_HEAD, 12, { position = "absolute", top = 0, left = inner - 30 }) local rate_head = fg.label(c, theme.currency .. "/h", TIER_HEAD, 12, { position = "absolute", top = 0, left = inner - 30 })
local rows = {} local rows = {}
for _, r in ipairs(tr.ladder or {}) do for _, r in ipairs(ladder_of(st, t, tr)) do
local color = r.current and "text" or (r.next and "stat_up" or "text_2") local color = r.current and "text" or (r.next and "stat_up" or "text_2")
local font = r.next and "mono_semi" or "mono" local font = r.next and "mono_semi" or "mono"
local price = "—" local price = "—"
@@ -668,7 +716,7 @@ function M.aside(self, root, w, h, H)
elseif open then elseif open then
add(space(0, 22)) -- (the line's room, kept at the top level: Sell drill stays put) add(space(0, 22)) -- (the line's room, kept at the top level: Sell drill stays put)
end end
add(ladder(st, c, tr, inner)) add(ladder(st, c, t, tr, inner))
end end
-- Idle copies while this one mines: deploy one on its best well. -- Idle copies while this one mines: deploy one on its best well.
+29
View File
@@ -273,6 +273,35 @@ function M.grade(g)
return st.moonGrade return st.moonGrade
end end
-- What the rows show (widgets/moon_row: name, grade, rating, status, warning, ready
-- badge, selection), both lists: the moon list's block signature has this instead of the
-- moons themselves, so a change the rows don't show (a well's projection, a drill's end)
-- keeps the list.
function M.rows_sig(self)
local st, v = self.state, self.view
local out, n = {}, 0
for _, list in ipairs({ st.moons, st.past }) do
for _, m in ipairs(list) do
local dry = not m.finished and #(m.wells or {}) > 0
if dry then
for _, w in ipairs(m.wells) do
if w.supply > 0 then
dry = false
break
end
end
end
n = n + 1
out[n] = { m.id, m.name, m.grade or false, m.rating or false, m.finished or false, m.mined or false,
m.finishedOn or false, m.finishedAt or false, dry, m.drills or 0, m.over or false,
not m.finished and ready.drills(self, m.id) or m.ready or 0, (m.selected and v.section == "moons") or false }
end
n = n + 1
out[n] = "|"
end
return blocks.sig(out)
end
function M.build(self, H, parent, x, y, w, h) function M.build(self, H, parent, x, y, w, h)
local st = self.state local st = self.state
local v = self.view local v = self.view
+318 -151
View File
@@ -19,6 +19,7 @@ local fg = require("modules.flex_gui")
local images = require("modules.images") local images = require("modules.images")
local test = require("modules.test") local test = require("modules.test")
local predict = require("modules.predict") local predict = require("modules.predict")
local blocks = require("modules.blocks")
local S = theme.size local S = theme.size
@@ -302,44 +303,95 @@ local function fit(p, str, style, opts)
return fg.label(p, str, style, 16, opts) return fg.label(p, str, style, 16, opts)
end end
-- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a -- Each part of the moon (its heading, a well's card, the multi-well card, the tray) is a
-- cycle gets here, or why it can't (no idle drills, no room). -- kept block (modules/blocks), built at its own origin and put in place: a deploy rebuilds
local function deploy_slot(g, st, well, groups, H) -- the cards it changed, not the panel. What a part shows is worked out first (its model,
-- from the state and our clock) and is its block's signature; the build draws only from
-- the model and the state, so a kept part shows what a rebuilt one would.
-- A kept part of the moon (key, sig: its model) at (x, y) of parent: fn(root) builds it
-- at root's origin and returns its height (also when it's kept). Parts nest: a card's
-- slots are parts of their own, so a card that changes keeps the slots that didn't.
local function part(g, key, sig, parent, x, y, fn)
local b, kept = blocks.open(g, key, sig, parent)
local h
if kept then
h = blocks.close(g, b)
else
h = blocks.close(g, b, fn(b.root))
end
gui.set_position(b.root, vec(x, -y))
return h
end
-- The busy and ready-pop marks (core/hooks: g.inflight, g.ready_at) of these action keys,
-- for a part's signature.
local function marks(g, keys)
local out = {}
for i, k in ipairs(keys) do
local r = g.ready_at and g.ready_at[k]
out[i] = (g.inflight[k] and "b" or "") .. (r and tostring(r.t) or "")
end
return out
end
-- What a well's deploy slot shows (the group it sends and its two lines).
local function deploy_view(g, st, well, groups)
local grp, here, amount, explicit = deploy_choice(g, st, well, groups) local grp, here, amount, explicit = deploy_choice(g, st, well, groups)
local ok = grp ~= nil and here > EPS local ok = grp ~= nil and here > EPS
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w) local v = { ok = ok, explicit = explicit or false, key = grp and grp.key or false,
-- (off: a plain line and no fill, so it reads as greyed beside a live slot) name = grp and grp.tool.name or false, tool = grp and grp.tool.id or false }
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not ok,
edge = not ok and "line" or nil, fill = not ok and { "cyan", 0 } or nil })
local title, sub, sub_color
if #groups == 0 then 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 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 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 else
local t = grp.tool local t = grp.tool
local reach = reach_short(st, t.reach) local reach = reach_short(st, t.reach)
title = "+ Deploy " .. M.short_name(t) v.title = "+ Deploy " .. M.short_name(t)
sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "") v.sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
if here < amount - EPS then 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
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 }, { 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, v.title, { font = "sans_semi", px = 13, color = v.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.sub, { font = "sans", px = 12, color = v.color or "muted" }),
}) })
fg.build(flex.solve(tree, 0, 0), b) fg.build(flex.solve(tree, 0, 0), b)
if ok then if v.ok then
local params = { tool = grp.tool.id, target = well.id } local function params()
H.action(g, b, bn, "stake", params) for _, grp in ipairs(M.idle_groups(g.state)) do
tooltip.add(b, "Deploy " .. (grp.tool.name or "a drill") .. (explicit and "" or " (the smallest idle drill with room here)")) if grp.key == v.key then
return { tool = grp.tool.id, target = well.id }
end 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)
end end
-- What a drill's slot shows besides the drill d: dm = { name, due, reach } (drill_models).
-- A drill's slot (SLOT_H tall; slides in when it was just deployed): its short name and -- A drill's slot (SLOT_H tall; slides in when it was just deployed): its short name and
-- time left over its timer bar and what it gets; done (ready, or its end passed on our -- time left over its timer bar and what it gets; done (ready, or its end passed on our
-- clock): gold, "+X ready", Claim (scope: claim-well or claim-wide) and, once unlocked, -- clock): gold, "+X ready", Claim (scope: claim-well or claim-wide) and, once unlocked,
@@ -347,11 +399,13 @@ end
-- the plain action ids). wait: the group's next drill to finish; where its Claim and -- the plain action ids). wait: the group's next drill to finish; where its Claim and
-- Redeploy will show is registered as "wait:claim?..." (like H.claim_wait's waiting -- Redeploy will show is registered as "wait:claim?..." (like H.claim_wait's waiting
-- button; not focusable), for the test driver. -- button; not focusable), for the test driver.
local function drill_slot(g, st, d, scope, can_redeploy, n, wait, H, enters) local function drill_slot(g, st, d, dm, scope, can_redeploy, n, wait, mk, H, enters)
local tool = find(st.tools, d.toolId) local due = dm.due
local due = d.ready or (d["end"] or math.huge) <= H.now(g) local reach = dm.reach or nil
local reach = reach_short(st, d.reach) return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w) -- (a part of its own: a card that changes keeps the slots that didn't)
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 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) 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) ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil)
@@ -410,7 +464,7 @@ local function drill_slot(g, st, d, scope, can_redeploy, n, wait, H, enters)
end end
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, { local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
row({ height = 16, align = "center" }, { row({ height = 16, align = "center" }, {
fit(body, M.short_name(tool), NAME13), fit(body, dm.name, NAME13),
reach and space(5) or nil, reach and fit(body, "· " .. reach, S12) or nil, reach and space(5) or nil, reach and fit(body, "· " .. reach, S12) or nil,
fg.fill(), space(6), right, fg.fill(), space(6), right,
}), }),
@@ -441,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) }) row_c.enter(g, "drill:" .. d.toolId, body, w, SLOT_H, { base = gui.get_color(body) })
end end
end) end)
end)
end end
-- The slot of the idle drills that last mined the well (well.redrill): how many, and -- The drill of a list that finishes next (not done yet), or nil.
-- Redrill (sends them out again) once unlocked and the well has supply. local function next_drill(g, drills)
local function idle_slot(g, st, well, H) local now, pick = g.h.now(g), nil
for _, d in ipairs(drills or {}) do
if not d.ready and d["end"] and d["end"] > now and (not pick or d["end"] < pick["end"]) then
pick = d
end
end
return pick
end
-- The drills of a group (a well's, or the multi-well ones) as their slots show them:
-- { name, due, reach, n (its place among the ready ones, 0: not ready), wait }.
local function drill_models(g, st, drills, H)
local now, next_d = H.now(g), next_drill(g, drills)
local out, n = {}, 0
for i, d in ipairs(drills or {}) do
local due = d.ready or (d["end"] or math.huge) <= now
n = n + (due and 1 or 0)
out[i] = { name = M.short_name(find(st.tools, d.toolId)), due = due and true or false,
reach = reach_short(st, d.reach) or false, n = due and n or 0, wait = d == next_d }
end
return out
end
-- What the idle slot of a well shows (well.redrill > 0).
local function idle_view(st, well)
local n = well.redrill or 0 local n = well.redrill or 0
local names = {} local names = {}
for _, t in ipairs(st.tools or {}) do for _, t in ipairs(st.tools or {}) do
@@ -456,21 +535,27 @@ local function idle_slot(g, st, well, H)
local who = #names > 0 and (table.concat(names, ", ") .. (n > #names and (" +" .. (n - #names)) or "")) or ui.plural(n, "drill") local who = #names > 0 and (table.concat(names, ", ") .. (n > #names and (" +" .. (n - #names)) or "")) or ui.plural(n, "drill")
-- (no Redrill while the drills on the well take all it has: the server refuses it) -- (no Redrill while the drills on the well take all it has: the server refuses it)
local full = well.supply > 0 and (well.remain or 1) <= 1e-9 local full = well.supply > 0 and (well.remain or 1) <= 1e-9
local can = st.unlocked.redrill and well.supply > 0 and not full return { n = n, who = who, full = full, can = (st.unlocked.redrill and well.supply > 0 and not full) and true or false }
end
-- The slot of the idle drills that last mined the well (well.redrill): how many, and
-- Redrill (sends them out again) once unlocked and the well has supply. v: idle_view.
local function idle_slot(g, well, v, H)
local n = v.n
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w) return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
local body = ui.rect(p, x, y, w, SLOT_H, "cyan", 0.02) local body = ui.rect(p, x, y, w, SLOT_H, "cyan", 0.02)
ui.outline(body, 0, 0, w, SLOT_H, "line_hi") ui.outline(body, 0, 0, w, SLOT_H, "line_hi")
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, { local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
row({ height = 16, align = "center" }, { row({ height = 16, align = "center" }, {
fit(body, who, { font = "sans_semi", px = 13, color = "text_2" }), fg.fill(), space(6), fit(body, v.who, { font = "sans_semi", px = 13, color = "text_2" }), fg.fill(), space(6),
fg.label(body, "Idle", { font = "sans_semi", px = 12, color = "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 }), fg.label(body, "Idle", { font = "sans_semi", px = 12, color = "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 }),
}), }),
can and fg.button(body, "Redrill" .. (n > 1 and (" " .. n) or ""), { small = true, variant = "redrill", h = SLOT_BTN_H }, function(b, bn) v.can and fg.button(body, "Redrill" .. (n > 1 and (" " .. n) or ""), { small = true, variant = "redrill", h = SLOT_BTN_H }, function(b, bn)
H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id }) H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
tooltip.add(b, "Send the drills that last mined this well out again") tooltip.add(b, "Send the drills that last mined this well out again")
end, { height = SLOT_BTN_H }) or row({ height = SLOT_BTN_H, align = "center" }, { end, { height = SLOT_BTN_H }) or row({ height = SLOT_BTN_H, align = "center" }, {
fit(body, well.supply <= 0 and "Last mined here · the well is empty" fit(body, well.supply <= 0 and "Last mined here · the well is empty"
or (full and "Last mined here · the drills on it take the rest" or "Last mined here"), S11), or (v.full and "Last mined here · the drills on it take the rest" or "Last mined here"), S11),
}), }),
}) })
fg.build(flex.solve(tree, 0, 0), body) fg.build(flex.solve(tree, 0, 0), body)
@@ -490,40 +575,53 @@ local function well_note(c, well)
end end
end end
-- The drill of a list that finishes next (not done yet), or nil. -- What a well's card shows (its block's signature with its rect): the well, its deploy
local function next_drill(g, drills) -- slot, its drills' slots, its idle slot. slots: how many (card_h).
local now, pick = g.h.now(g), nil local function card_model(g, st, well, max, groups, H)
for _, d in ipairs(drills or {}) do
if not d.ready and d["end"] and d["end"] > now and (not pick or d["end"] < pick["end"]) then
pick = d
end
end
return pick
end
-- One well's card (a grid cell; its node selects the well when clicked).
local function well_card(g, c, st, moon, well, max, groups, H, enters)
local dry = well.supply <= 0 local dry = well.supply <= 0
local selected = well.id == g.view.well
local scope = { ["claim-well"] = well.id }
local slots = {}
if not dry then
slots[#slots + 1] = deploy_slot(g, st, well, groups, H)
end
local n, next_d = 0, next_drill(g, well.drills)
-- Tapped out once the ready drills are claimed (nothing left, or what is left is the -- Tapped out once the ready drills are claimed (nothing left, or what is left is the
-- other drills' on it, the server's `remain`): Claim only, no Redeploy. -- other drills' on it, the server's `remain`): Claim only, no Redeploy.
local tapped = well.supply - ready.amount(g, well, well.cap) <= 1e-9 or (well.remain or 1) <= 1e-9 local tapped = well.supply - ready.amount(g, well, well.cap) <= 1e-9 or (well.remain or 1) <= 1e-9
for _, d in ipairs(well.drills or {}) do local m = {
local due = d.ready or (d["end"] or math.huge) <= H.now(g) well = well, max = max, dry = dry, selected = well.id == g.view.well,
n = n + (due and 1 or 0) redeploy = (not dry and not tapped and st.unlocked.redeploy) and true or false,
slots[#slots + 1] = drill_slot(g, st, d, scope, not dry and not tapped and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters) deploy = not dry and deploy_view(g, st, well, groups) or false,
drills = drill_models(g, st, well.drills, H),
idle = (well.redrill or 0) > 0 and idle_view(st, well) or false,
}
m.slots = (dry and 0 or 1) + #m.drills + (m.idle and 1 or 0)
local scope = "claim?claim-well=" .. well.id
m.marks = marks(g, { scope, scope .. "&redeploy=1", "stake?target=" .. well.id, "redrill?redrill-well=" .. well.id })
return m
end
-- A card's height for its slots (its tree's, below: the head lines, then the slots).
local CARD_HEAD = 18 + 7 + 6 + 4 + 16 + 8
local function card_h(slots)
return CARD_PAD * 2 + CARD_HEAD + (slots > 0 and (slots * SLOT_H + (slots - 1) * SLOT_GAP) or 0)
end
-- One well's card, w x h at the origin of c (its node selects the well when clicked).
-- m: card_model.
local function well_card(g, c, st, moon, m, w, h, H)
local well = m.well
local dry = m.dry
local scope = { ["claim-well"] = well.id }
local enters = {}
local slots = {}
if m.deploy then
slots[#slots + 1] = deploy_slot(g, well, m.deploy, m.marks, H)
end end
if (well.redrill or 0) > 0 then for i, d in ipairs(well.drills or {}) do
slots[#slots + 1] = idle_slot(g, st, well, H) local dm = m.drills[i]
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
end
if m.idle then
slots[#slots + 1] = idle_slot(g, well, m.idle, H)
end end
local sup = fit(c, ui.money(well.supply), { font = "mono", px = 13, color = dry and "muted" or "text" }, { shrink = 0 }) local sup = fit(c, ui.money(well.supply), { font = "mono", px = 13, color = dry and "muted" or "text" }, { shrink = 0 })
sup.done = function(node) H.tick_num(g, node, "supply:" .. well.id, well.supply) end sup.done = function(node) H.tick_num(g, node, "supply:" .. well.id, well.supply) end
local list = col({ gap = SLOT_GAP }, slots)
local kids = { local kids = {
row({ height = 18, align = "center" }, { row({ height = 18, align = "center" }, {
made({ width = 8, height = 8, shrink = 0 }, function(q, x, y) ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1) end), made({ width = 8, height = 8, shrink = 0 }, function(q, x, y) ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1) end),
@@ -531,48 +629,64 @@ local function well_card(g, c, st, moon, well, max, groups, H, enters)
fg.fill(), space(6), sup, fg.fill(), space(6), sup,
}), }),
space(0, 7), space(0, 7),
made({ height = 6, shrink = 0 }, function(q, x, y, w) made({ height = 6, shrink = 0 }, function(q, x, y, bw)
if dry then if dry then
ui.rect(q, x, y, w, 6, "line") ui.rect(q, x, y, bw, 6, "line")
else else
M.supply_bar(q, x, y, w, well, max) M.supply_bar(q, x, y, bw, well, m.max)
end end
end), end),
space(0, 4), space(0, 4),
fg.reserve({ height = 16 }, well_note(c, well)), fg.reserve({ height = 16 }, well_note(c, well)),
space(0, 8), space(0, 8),
col({ gap = SLOT_GAP }, slots), list,
} }
return col({ padding = CARD_PAD }, kids, { make = function(parent, x, y, w, h) local tree = col({ padding = CARD_PAD }, kids, { make = function(parent, x, y, cw, ch)
local slot = ui.rect(parent, x, y, w, h, "none") local slot = ui.rect(parent, x, y, cw, ch, "none")
local card = ui.rect(slot, 0, 0, w, h, "cyan", 0) local card = ui.rect(slot, 0, 0, cw, ch, "cyan", 0)
row_c.bind(g, card, w, h, { selected = selected, id = "well:" .. well.id, label = well.resource, row_c.bind(g, card, cw, ch, { selected = m.selected, id = "well:" .. well.id, label = well.resource,
on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end }) on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
ui.outline(card, 0, 0, w, h, selected and "cyan" or "line_hi", selected and 0.9 or nil) ui.outline(card, 0, 0, cw, ch, m.selected and "cyan" or "line_hi", m.selected and 0.9 or nil)
g.well_nodes[well.id] = card blocks.put(g, "well_nodes", well.id, card)
return card return card
end }) end })
fg.build(flex.solve(tree, 0, 0, w, h), c)
if math.abs(list.y + list.h + CARD_PAD - card_h(m.slots)) > 0.5 then
print("ERROR:MM: moons: a well card's height is " .. (list.y + list.h + CARD_PAD) .. ", card_h says " .. card_h(m.slots))
end
for _, f in ipairs(enters) do
f()
end
end end
-- The multi-well drills (moon.wide: reach > 1, claimed together with claim-wide): a card -- The multi-well drills (moon.wide: reach > 1, claimed together with claim-wide): what its
-- across the grid, its slots in the cards' columns. -- card shows.
local function wide_card(g, c, st, moon, inner, H, enters) local function wide_model(g, st, moon, H)
local wide = moon.wide local wide = moon.wide
local scope = { ["claim-wide"] = moon.id }
local slots, n, next_d = {}, 0, next_drill(g, wide.drills)
-- (the moon tapped out once these are claimed: no Redeploy) -- (the moon tapped out once these are claimed: no Redeploy)
local left = 0 local left = 0
for _, w in ipairs(moon.wells or {}) do for _, w in ipairs(moon.wells or {}) do
left = left + w.supply left = left + w.supply
end end
local tapped = left - ready.amount(g, wide, wide.cap) <= 1e-9 local tapped = left - ready.amount(g, wide, wide.cap) <= 1e-9
for _, d in ipairs(wide.drills or {}) do local scope = "claim?claim-wide=" .. moon.id
local due = d.ready or (d["end"] or math.huge) <= H.now(g) return { wide = wide, redeploy = (not tapped and st.unlocked.redeploy) and true or false,
n = n + (due and 1 or 0) drills = drill_models(g, st, wide.drills, H), marks = marks(g, { scope, scope .. "&redeploy=1" }) }
slots[#slots + 1] = drill_slot(g, st, d, scope, not tapped and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters) end
-- Its card across the grid (inner wide, at the origin of c), its slots in the cards'
-- columns. Returns its height.
local function wide_card(g, c, st, moon, m, inner, H)
local wide = m.wide
local scope = { ["claim-wide"] = moon.id }
local enters = {}
local slots = {}
for i, d in ipairs(wide.drills or {}) do
local dm = m.drills[i]
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
end end
local sw = inner - CARD_PAD * 2 local sw = inner - CARD_PAD * 2
return col({ width = inner, padding = CARD_PAD }, { local tree = col({ width = inner, padding = CARD_PAD }, {
row({ height = 18, align = "center" }, { row({ height = 18, align = "center" }, {
fg.label(c, "Multi-well", { font = "sans_semi", px = 15, color = "text" }, 18), space(10), fg.label(c, "Multi-well", { font = "sans_semi", px = 15, color = "text" }, 18), space(10),
fit(c, ui.money(wide.cap or 0) .. " left on the moon", S12), fg.fill(), fit(c, ui.money(wide.cap or 0) .. " left on the moon", S12), fg.fill(),
@@ -584,28 +698,16 @@ local function wide_card(g, c, st, moon, inner, H, enters)
ui.outline(card, 0, 0, w, h, "line_hi") ui.outline(card, 0, 0, w, h, "line_hi")
return card return card
end }) end })
fg.build(flex.solve(tree, 0, 0), c)
for _, f in ipairs(enters) do
f()
end
return tree.h
end end
-- The tray of idle drills: a tile per group (picture, short name, ×count, what a cycle -- What the tray of idle drills shows: a tile per group (the well it deploys to: the
-- gets); a tile toggles the pick the deploy slots send. -- selected one, else the first it has room on).
local function tray(g, c, st, groups, inner, H) local function tray_model(g, st, groups)
local head = row({ height = 16, align = "center" }, {
fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0),
#groups > 0 and fit(c, "Click one to deploy it to the selected well", "small") or nil,
})
if #groups == 0 then
return col({ width = inner }, {
head, space(0, 8),
row({ height = 16, align = "center" }, {
fg.label(c, "No idle drills.", "small", 16),
st.unlocked.store and space(5) or nil,
st.unlocked.store and made({ width = 160, height = 16, shrink = 0 }, function(p, x, y)
H.text_link(g, p, "Buy one in the store", { font = "sans", px = 13 }, x, y + 8, { section = "store", moon = g.view.moon })
end) or nil,
}),
})
end
-- The well a tile deploys to: the selected one, else the first it has room on.
local moon = st.moon local moon = st.moon
local function target(t) local function target(t)
local wells = moon and moon.wells or {} local wells = moon and moon.wells or {}
@@ -620,40 +722,78 @@ local function tray(g, c, st, groups, inner, H)
end end
end end
end end
local tiles = {} local tiles, keys = {}, {}
for _, grp in ipairs(groups) do for i, grp in ipairs(groups) do
local t = grp.tool local t = grp.tool
local well = target(t) local well = target(t)
local here = well and gets_here(st, well, t) or 0 local here = well and gets_here(st, well, t) or 0
local ok = well ~= nil and well.supply > 0 and here > EPS tiles[i] = { key = grp.key, name = M.short_name(t), count = grp.count, tool = t.id, tname = t.name or false, image = t.image or false,
local picked = false gets = "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach_short(st, t.reach) and (" · " .. reach_short(st, t.reach)) or ""),
tiles[#tiles + 1] = made({ height = TILE_H }, function(p, x, y, w) well = well and well.id or false, resource = well and well.resource or false,
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = TILE_H, disabled = not ok }) ok = (well ~= nil and well.supply > 0 and here > EPS) and true or false }
local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg") if well then
if t.image then keys[#keys + 1] = "stake?target=" .. well.id .. "&tool=" .. t.id
images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), t.image)
end end
local reach = reach_short(st, t.reach) end
local tree = col({ width = w, height = TILE_H, padding = { 8, 8, 8, TILE_H }, gap = 4 }, { return { tiles = tiles, store = st.unlocked.store and true or false, moon = g.view.moon, marks = marks(g, keys) }
row({ height = 16, align = "center" }, { end
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 }), -- 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
fit(b, "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or ""), { font = "mono", px = 11, color = "muted" }), -- 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 if picked then
ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9) ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9)
end end
if ok then if tv.ok then
tooltip.add(b, "Deploy " .. (t.name or "a drill") .. " to the " .. well.resource .. " well") tooltip.add(b, "Deploy " .. (tv.tname or "a drill") .. " to the " .. tv.resource .. " well")
H.action(g, b, bn, "stake", { tool = t.id, target = well.id }) H.action(g, b, bn, "stake", { tool = tv.tool, target = tv.well })
else 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)
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 end
@@ -685,13 +825,10 @@ local function rating_chip(c, r)
end) end)
end end
-- The moon: heading (id, name, class and rating, description; Claim at the right), the
-- well cards, the multi-well card, the tray of idle drills, and selling the moon (once
-- the store is open). min_h tall at least. Returns its bottom.
local function moon_view(g, c, px, py, inner, min_h, H)
local st = g.state
local moon = st.moon
-- The heading (id, name, class and rating, description; Claim at the right), inner wide
-- at the origin of c, 80 tall at least. Returns its height.
local function heading(g, c, moon, n_ready, inner, H)
-- The class line ("<type> class"), but not for moons named after their type (from -- The class line ("<type> class"), but not for moons named after their type (from
-- before moon names), like the web. -- before moon names), like the web.
local has_class = moon.kind and moon.kind ~= "" and moon.kind ~= moon.name local has_class = moon.kind and moon.kind ~= "" and moon.kind ~= moon.name
@@ -715,7 +852,6 @@ local function moon_view(g, c, px, py, inner, min_h, H)
head[#head + 1] = space(0, 4) head[#head + 1] = space(0, 4)
end end
-- Claim, at the top right (out of the flow: the heading's text keeps clear of it). -- Claim, at the top right (out of the flow: the heading's text keeps clear of it).
local n_ready = ready.drills(g, moon.id)
if moon.ready > 0 then if moon.ready > 0 then
head[#head + 1] = made({ position = "absolute", top = 26, right = 0 }, function(p, x, y) head[#head + 1] = made({ position = "absolute", top = 26, right = 0 }, function(p, x, y)
local b, nodes, bw = ui.button(p, "Claim " .. ui.plural(n_ready, "drill"), x, y, { primary = true }) local b, nodes, bw = ui.button(p, "Claim " .. ui.plural(n_ready, "drill"), x, y, { primary = true })
@@ -729,9 +865,31 @@ local function moon_view(g, c, px, py, inner, min_h, H)
done = n_ready > 0 and ("Claim " .. ui.plural(n_ready, "drill")) or nil }) done = n_ready > 0 and ("Claim " .. ui.plural(n_ready, "drill")) or nil })
end) end)
end end
local tree = col({ width = inner, min_height = 80, align = "start" }, head)
fg.build(flex.solve(tree, 0, 0), c)
return tree.h
end
-- The cards: a well each (as many columns as CARD_MIN allows), the multi-well card -- The moon: heading, the well cards, the multi-well card, the tray of idle drills (selling
-- across them, then the idle drills. -- the moon is in the aside: aside.lua), each a kept part, top to bottom from (px, py) of
-- c. min_h tall at least. Returns its bottom.
local function moon_view(g, c, px, py, inner, min_h, H)
local st = g.state
local moon = st.moon
local now = H.now(g)
local y = py
local n_ready = ready.drills(g, moon.id)
local claim = "claim?claim-moon=" .. moon.id
y = y + part(g, "mo:head", blocks.sig(inner, moon.id, moon.name, moon.kind or false, moon.grade or false,
moon.rating or false, moon.description or false, moon.ready, moon.nextEnd or false,
(moon.nextEnd or math.huge) <= now, n_ready, marks(g, { claim })), c, px, y, function(root)
return heading(g, root, moon, n_ready, inner, H)
end)
y = y + 14
-- The cards: a well each (as many columns as CARD_MIN allows; a row as tall as its
-- tallest card), the multi-well card across them, then the idle drills.
local max = M.supply_max(st, moon) local max = M.supply_max(st, moon)
local groups = M.idle_groups(st) local groups = M.idle_groups(st)
local pick_ok = false local pick_ok = false
@@ -741,30 +899,39 @@ local function moon_view(g, c, px, py, inner, min_h, H)
if not pick_ok then if not pick_ok then
g.deploy_pick = nil -- (used up) g.deploy_pick = nil -- (used up)
end end
local enters = {} local cols, cw = flex.columns(inner, CARD_MIN, CARD_GAP)
local cards = {} local wells = moon.wells or {}
for _, well in ipairs(moon.wells) do local models = {}
cards[#cards + 1] = well_card(g, c, st, moon, well, max, groups, H, enters) for i, well in ipairs(wells) do
models[i] = card_model(g, st, well, max, groups, H)
end
for first = 1, #wells, cols do
local last = math.min(#wells, first + cols - 1)
local rh = 0
for i = first, last do
rh = math.max(rh, card_h(models[i].slots))
end
for i = first, last do
local m = models[i]
part(g, "mo:well:" .. wells[i].id, blocks.sig(cw, rh, m), c, px + (i - first) * (cw + CARD_GAP), y, function(root)
well_card(g, root, st, moon, m, cw, rh, H)
end)
end
y = y + rh + (last < #wells and CARD_GAP or 0)
end end
local kids = {
col({ min_height = 80, align = "start" }, head),
space(0, 14),
flex.grid(inner, CARD_MIN, CARD_GAP, cards),
}
if moon.wide and #(moon.wide.drills or {}) > 0 then if moon.wide and #(moon.wide.drills or {}) > 0 then
kids[#kids + 1] = space(0, CARD_GAP) y = y + CARD_GAP
kids[#kids + 1] = wide_card(g, c, st, moon, inner, H, enters) local m = wide_model(g, st, moon, H)
y = y + part(g, "mo:wide", blocks.sig(inner, moon.id, m), c, px, y, function(root)
return wide_card(g, root, st, moon, m, inner, H)
end)
end end
kids[#kids + 1] = space(0, 20) y = y + 20
kids[#kids + 1] = tray(g, c, st, groups, inner, H) local tm = tray_model(g, st, groups)
-- (selling the moon, a destructive action, is in the aside: aside.lua) y = y + part(g, "mo:tray", blocks.sig(inner, tm), c, px, y, function(root)
return tray(g, root, tm, inner, H)
local page = col({ width = inner, min_height = min_h }, kids) end)
fg.build(flex.solve(page, px, py), c) return math.max(y, py + min_h)
for _, f in ipairs(enters) do
f()
end
return page.y + page.h
end end
+7
View File
@@ -117,7 +117,14 @@ up() {
local s=$WT/server ms=$MAIN/server local s=$WT/server ms=$MAIN/server
[[ -e $s/web/static/css/app.css || ! -e $ms/web/static/css/app.css ]] || { mkdir -p "$s/web/static/css"; cp "$ms/web/static/css/app.css" "$s/web/static/css/"; } [[ -e $s/web/static/css/app.css || ! -e $ms/web/static/css/app.css ]] || { mkdir -p "$s/web/static/css"; cp "$ms/web/static/css/app.css" "$s/web/static/css/"; }
[[ -e $s/web/static/app || ! -d $ms/web/static/app ]] || cp -a "$ms/web/static/app" "$s/web/static/app" [[ -e $s/web/static/app || ! -d $ms/web/static/app ]] || cp -a "$ms/web/static/app" "$s/web/static/app"
# The copied .env names this worktree's own database, so anything run here (migrate,
# tests, the server) reaches mm_<task>, never the main checkout's.
[[ -e $WT/.env ]] || cp "$MAIN/.env" "$WT/.env" [[ -e $WT/.env ]] || cp "$MAIN/.env" "$WT/.env"
if grep -q '^DB_DATABASE=' "$WT/.env"; then
sed -i "s/^DB_DATABASE=.*/DB_DATABASE=$DB/" "$WT/.env"
else
echo "DB_DATABASE=$DB" >>"$WT/.env"
fi
echo "$DB" >"$ST/db" echo "$DB" >"$ST/db"
if [[ -z $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then if [[ -z $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then
admin_sql "create database $DB" admin_sql "create database $DB"
+11 -4
View File
@@ -25,7 +25,9 @@ every command below from this directory (`server/`).
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells `GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they can't be sold. The Past tab loads them 10 at a empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they can't be sold. The Past tab loads them 10 at a
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
`loadView` (state, the past page, the resolved view). `loadView` (state, the past page, the resolved view). `loadState` leaves moon
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=`), - `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. the `Page` view model, formatting helpers, the admin templates' functions.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body - `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
@@ -75,7 +77,9 @@ every command below from this directory (`server/`).
recent sales of the item, in base-price credits, decaying with a half-life, table recent sales of the item, in base-price credits, decaying with a half-life, table
`market_pressure`; the factor eases from 1 towards `floor`, and a sale is priced at its `market_pressure`; the factor eases from 1 towards `floor`, and a sale is priced at its
average over the pressure it adds). Personal modifiers never change the quote, cards, average over the pressure it adds). Personal modifiers never change the quote, cards,
ticker or 24h history (recomputed, no table); the sell ticket shows them as lines ("+8% ticker or 24h history (recomputed, no table; each item's 24h series is cached for the
current tick and config snapshot, `marketSeriesAt`, shared by every request; live events
are applied on top per request); the sell ticket shows them as lines ("+8%
for you", "−4% from your recent selling"). View model for the Market section (ticker, for you", "−4% from your recent selling"). View model for the Market section (ticker,
watchlist, chart, sell ticket; `pick=` selects a quote). 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 - `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
@@ -143,7 +147,7 @@ every command below from this directory (`server/`).
via `eventMultiplier` in game.go, `announcement`; both in the API's `banner`). via `eventMultiplier` in game.go, `announcement`; both in the API's `banner`).
- `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in - `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in
use; the tools show their original images. use; the tools show their original images.
- `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. changes for existing ones, applied on every `migrate`, so each must be idempotent.
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format. - `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
- `art/`: sources of the store and advisor images (Blender scripts, prompts); - `art/`: sources of the store and advisor images (Blender scripts, prompts);
@@ -163,7 +167,10 @@ every command below from this directory (`server/`).
Agents: `../scripts/wt up <task>` gives a worktree its own database and server instead. Agents: `../scripts/wt up <task>` gives a worktree its own database and server instead.
Other tests: `go test ./...` (game rules on in-memory states, no database); 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, `wells_test.go` (`go test -run Wells -v`: well roll distribution, value by well count,
resale odds, sim seeds). resale odds, sim seeds). `claim_db_test.go` needs a database of its own
(`MM_TEST_DATABASE=mm_<task>`, from `scripts/wt`; skipped without it): `TestClaimGolden`
compares claim-all's results with `testdata/claim_golden.txt` (`MM_CLAIM_GOLDEN_WRITE=1`
rewrites it, only for a deliberate rule change), `TestClaimAllPerf -v` times it.
## Deployment ## Deployment
- Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`; - Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`;
+7 -4
View File
@@ -43,7 +43,8 @@ every interface), `DB_SSLMODE` (default `disable`), `ADMIN_PASSWORD` (the
password of the admin pages, `/utils`; unset: they're locked), `ADMIN_USER_IDS` 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`, (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 saving moon settings), `MM_DEV=1` (reload templates and static files from disk; no admin
login, and every player is an admin), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit by 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 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). Secure on HTTPS; new players are limited to 10 a minute per IP).
@@ -58,8 +59,9 @@ Secure on HTTPS; new players are limited to 10 a minute per IP).
make migrate make migrate
``` ```
3. Run the server (`MM_DEV=1`: no admin login) and open the admin at 3. Run the server (`MM_DEV=1`: no admin login). The admin is at
http://localhost:3000/utils, or point the Defold client at it: http://localhost:3000/utils; the game at http://localhost:3000 once the client's
`make web` has built it (then restart the server), or run the desktop client against it:
```bash ```bash
make dev make dev
@@ -99,7 +101,8 @@ attachments (dropped in 020) and advisors, 011 market pressure, 012 onboarding,
rating, 016 advisor seats, 017 save codes, 018 moon grade, 019 refinery batch size rating, 016 advisor seats, 017 save codes, 018 moon grade, 019 refinery batch size
(`users.refinery_batch_level`), 020 drill upgrade tracks (`inventory_item.yield_level`, (`users.refinery_batch_level`), 020 drill upgrade tracks (`inventory_item.yield_level`,
`cycle_level`; old tiers became cycle levels, attachments were refunded and dropped, `cycle_level`; old tiers became cycle levels, attachments were refunded and dropped,
`upgrade_item` dropped, stake cycles in tenths of a second). `upgrade_item` dropped, stake cycles in tenths of a second), 022 indexes for the per-request
state reads (moons, wells, tools and stakes by player, stakes by well and by tool).
## Game config ## Game config
+6
View File
@@ -688,6 +688,12 @@ func (s *server) apiPage(ctx context.Context, userID string, v View, get func(st
// apiPageFor builds the page for a resolved view, with what the server loads for it. // apiPageFor builds the page for a resolved view, with what the server loads for it.
func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, error) { func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, error) {
if v.Section == "moons" {
// The moon view is the only one that shows a description (loadState leaves them out).
if err := loadMoonDescription(ctx, s.db, state, v.Moon); err != nil {
return nil, err
}
}
page := state.page(v) page := state.page(v)
if err := s.loadRanking(ctx, page); err != nil { if err := s.loadRanking(ctx, page); err != nil {
return nil, err return nil, err
+13 -9
View File
@@ -166,14 +166,15 @@ short code, listed with each field, and the web's text next to it.
``` ```
{tools: [{id, storeItemId, name, code, image, yieldLevel, cycleLevel, count, idle, reach, cycleSecs, yield, {tools: [{id, storeItemId, name, code, image, yieldLevel, cycleLevel, count, idle, reach, cycleSecs, yield,
selected, stake, tracks, resale, noSale, noSaleText, bonus}], selected, stake, tracks, ladderKey, resale, noSale, noSaleText, bonus}],
ladders: {<ladderKey>: {yield: [steps], cycle: [steps]}},
drill} drill}
``` ```
- Every tool is its own row, copies too: `count` is 1, `idle` 1 when it isn't mining, `stake` - Every tool is its own row, copies too: `count` is 1, `idle` 1 when it isn't mining, `stake`
its drill. its drill.
- `tracks`: the tool's two upgrade tracks, yield then cycle: `{kind, name, level, max, value, - `tracks`: the tool's two upgrade tracks, yield then cycle: `{kind, name, level, max, value,
next, ladder}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds next}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds
per cycle); values are a stake's now, with the player's bonuses and live events. Upgrade per cycle); values are a stake's now, with the player's bonuses and live events. Upgrade
with `tool=<id>&track=<kind>`. with `tool=<id>&track=<kind>`.
- `next`: the next level, `null` at the top: `{level, value, label, price, parts: [{name, - `next`: the next level, `null` at the top: `{level, value, label, price, parts: [{name,
@@ -184,8 +185,10 @@ short code, listed with each field, and the web's text next to it.
0). Make it in the Refinery"), `shortText` the line under it when credits are short; 0). Make it in the Refinery"), `shortText` the line under it when credits are short;
`rateFrom`/`rateTo` credits per hour now and after it, `cost` its price plus the parts at `rateFrom`/`rateTo` credits per hour now and after it, `cost` its price plus the parts at
sell value, `paybackSecs` null if it never pays back. sell value, `paybackSecs` null if it never pays back.
- `ladder`: every level from 0 `[{level, value, rate, price, parts: [{name, need, have}], - `ladders`: each track's ladder, sent once for every tool with the same store item and
current, next}]` (the other track as it is; `price` 0 for level 0). levels: `ladders[ladderKey][kind]` (`ladderKey` is `"<storeItemId>:<yieldLevel>:<cycleLevel>"`)
is every level from 0 `[{level, value, rate, price, parts: [{name, need, have}], current,
next}]` (the other track as it is; `price` 0 for level 0).
- `resale`: what it sells for (its price and its levels' prices × `rules.equipmentResale`); - `resale`: what it sells for (its price and its levels' prices × `rules.equipmentResale`);
`noSale`: `only-tool`, `claim-first`, `mining` or `""`, `noSaleText` its text ("Claim its `noSale`: `only-tool`, `claim-first`, `mining` or `""`, `noSaleText` its text ("Claim its
haul first"). haul first").
@@ -454,11 +457,12 @@ From a live server (long ids and arrays shortened).
"tracks": [{"kind": "yield", "name": "Twin rotors", "level": 1, "max": 4, "value": 5, "tracks": [{"kind": "yield", "name": "Twin rotors", "level": 1, "max": 4, "value": 5,
"next": {"level": 2, "value": 6, "label": "Twin rotors 2", "price": 320, "parts": [], "next": {"level": 2, "value": 6, "label": "Twin rotors 2", "price": 320, "parts": [],
"canAfford": true, "blocked": "", "short": 0, "blockedText": "", "shortText": "", "canAfford": true, "blocked": "", "short": 0, "blockedText": "", "shortText": "",
"rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7}, "rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7}},
"ladder": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false}, {"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…"}],
{"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"]}, "ladderKey": "1:1:2", "resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}],
{"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…", "ladder": ["…"]}], "ladders": {"1:1:2": {"yield": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false},
"resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}], {"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"],
"cycle": ["…"]}},
"drill": {"toolId": "13b3…", "mining": false, "resale": 980, "noSale": "", "noSaleText": "", "drill": {"toolId": "13b3…", "mining": false, "resale": 980, "noSale": "", "noSaleText": "",
"confirm": {"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok": "Sell for ₥980", "danger": true}, "confirm": {"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok": "Sell for ₥980", "danger": true},
"idleSame": 0, "idleBest": "", "deployTo": null}} "idleSame": 0, "idleBest": "", "deployTo": null}}
+31 -8
View File
@@ -9,6 +9,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"math" "math"
) )
@@ -304,6 +305,9 @@ type APILiveEvent struct {
type APIEquipment struct { type APIEquipment struct {
Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped
// 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> Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
} }
@@ -323,6 +327,7 @@ type APIToolRow struct {
Selected bool `json:"selected"` Selected bool `json:"selected"`
Stake *APIStake `json:"stake"` // the shown copy's drill Stake *APIStake `json:"stake"` // the shown copy's drill
Tracks []APITrack `json:"tracks"` // yield, then cycle Tracks []APITrack `json:"tracks"` // yield, then cycle
LadderKey string `json:"ladderKey"` // its tracks' ladders in equipment.ladders
Resale float64 `json:"resale"` Resale float64 `json:"resale"`
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
NoSaleText string `json:"noSaleText"` NoSaleText string `json:"noSaleText"`
@@ -338,7 +343,6 @@ type APITrack struct {
Max int `json:"max"` Max int `json:"max"`
Value float64 `json:"value"` Value float64 `json:"value"`
Next *APINextLevel `json:"next"` // null at the top level Next *APINextLevel `json:"next"` // null at the top level
Ladder []APILadderStep `json:"ladder"`
} }
type APINextLevel struct { type APINextLevel struct {
@@ -399,11 +403,7 @@ func apiParts(rows []PartRow) []APIPart {
func apiTracks(s *State, rows []TrackRow) []APITrack { func apiTracks(s *State, rows []TrackRow) []APITrack {
out := []APITrack{} out := []APITrack{}
for _, r := range rows { for _, r := range rows {
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value, Ladder: []APILadderStep{}} t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value}
for _, st := range r.Ladder {
t.Ladder = append(t.Ladder, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
}
if n := r.Next; n != nil { if n := r.Next; n != nil {
next := &APINextLevel{Level: r.Level + 1, Value: r.NextValue, Label: r.Label(), Price: n.Price, next := &APINextLevel{Level: r.Level + 1, Value: r.NextValue, Label: r.Label(), Price: n.Price,
Parts: apiParts(r.Parts), CanAfford: r.CanAfford, Short: max(0, n.Price-s.Balance), Parts: apiParts(r.Parts), CanAfford: r.CanAfford, Short: max(0, n.Price-s.Balance),
@@ -427,15 +427,38 @@ func apiTracks(s *State, rows []TrackRow) []APITrack {
return out return out
} }
// ladderKey names a tool's ladders: "<store item>:<yield level>:<cycle level>".
func ladderKey(storeItemID int, lv Levels) string {
return fmt.Sprintf("%d:%d:%d", storeItemID, lv.Yield, lv.Cycle)
}
// apiLadders are the item's ladders at the levels, by kind (the tracks it has).
func apiLadders(s *State, item *StoreItem, lv Levels, tracks []TrackRow) map[string][]APILadderStep {
out := map[string][]APILadderStep{}
for _, r := range tracks {
steps := []APILadderStep{}
for _, st := range s.trackLadder(item, lv, r.Kind) {
steps = append(steps, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
}
out[r.Kind] = steps
}
return out
}
func apiEquipment(p *Page) *APIEquipment { func apiEquipment(p *Page) *APIEquipment {
s := p.S s := p.S
b := s.Bonus() b := s.Bonus()
e := &APIEquipment{Tools: []APIToolRow{}} e := &APIEquipment{Tools: []APIToolRow{}, Ladders: map[string]map[string][]APILadderStep{}}
for _, row := range p.Equipment { for _, row := range p.Equipment {
key := ladderKey(row.StoreItemID, row.Levels())
if _, ok := e.Ladders[key]; !ok {
e.Ladders[key] = apiLadders(s, row.Item, row.Levels(), row.Tracks)
}
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name), t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
Image: itemImages + row.Item.Image, YieldLevel: row.YieldLevel, CycleLevel: row.CycleLevel, Image: itemImages + row.Item.Image, YieldLevel: row.YieldLevel, CycleLevel: row.CycleLevel,
Count: row.Count, Idle: row.Idle, Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield, Count: row.Count, Idle: row.Idle, Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield,
Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), Resale: row.Item.ToolResale(row.Levels()), Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), LadderKey: key, Resale: row.Item.ToolResale(row.Levels()),
NoSale: s.noSale(row), NoSaleText: noSaleText[s.noSale(row)], Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}} NoSale: s.noSale(row), NoSaleText: noSaleText[s.noSale(row)], Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}}
if row.Stake != nil { if row.Stake != nil {
st := apiStake(*row.Stake, false) st := apiStake(*row.Stake, false)
+1 -1
View File
@@ -204,7 +204,7 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
} }
for _, tr := range r.Tracks { for _, tr := range r.Tracks {
if n := tr.Next; n != nil && ((n.Blocked != "") != (n.BlockedText != "") || n.Label == "") || if n := tr.Next; n != nil && ((n.Blocked != "") != (n.BlockedText != "") || n.Label == "") ||
len(tr.Ladder) != tr.Max+1 { len(e.Ladders[r.LadderKey][tr.Kind]) != tr.Max+1 {
fail("track texts", tr) fail("track texts", tr)
} }
} }
+639
View File
@@ -0,0 +1,639 @@
package main
// Claim-all against a real database: TestClaimGolden plays fixed scenarios (one-well and
// multi-well drills on shared wells, wells emptied mid-claim, redeploy on and off, every
// scope, reach 1, 3 and 5) and compares what they leave in the database with
// testdata/claim_golden.txt; TestClaimAllPerf times claim-all and counts its statements.
// Both need MM_TEST_DATABASE, a migrated database of their own (scripts/wt makes one:
// mm_<task>); without it they are skipped. MM_CLAIM_GOLDEN_WRITE=1 rewrites the golden file.
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"os"
"slices"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// countTracer counts the statements a pool sends and keeps the slowest.
type countTracer struct {
n atomic.Int64
mu sync.Mutex
slow []string
}
type traceStart struct {
sql string
at time.Time
}
func (c *countTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, d pgx.TraceQueryStartData) context.Context {
c.n.Add(1)
return context.WithValue(ctx, c, traceStart{d.SQL, time.Now()})
}
func (c *countTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryEndData) {
if s, ok := ctx.Value(c).(traceStart); ok {
if d := time.Since(s.at); d > 10*time.Millisecond {
c.mu.Lock()
c.slow = append(c.slow, fmt.Sprintf("%v %s", d.Round(time.Millisecond), strings.Join(strings.Fields(s.sql), " ")))
c.mu.Unlock()
}
}
}
func (c *countTracer) TraceBatchStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceBatchStartData) context.Context {
return ctx
}
func (c *countTracer) TraceBatchQuery(context.Context, *pgx.Conn, pgx.TraceBatchQueryData) {
c.n.Add(1)
}
func (c *countTracer) TraceBatchEnd(context.Context, *pgx.Conn, pgx.TraceBatchEndData) {}
func testDB(t *testing.T) (*pgxpool.Pool, *countTracer) {
t.Helper()
name := os.Getenv("MM_TEST_DATABASE")
if name == "" {
t.Skip("MM_TEST_DATABASE not set")
}
if name == "moonminers" {
t.Fatal("MM_TEST_DATABASE must not be the game's database")
}
if !loadDotEnv(".env") {
loadDotEnv("../.env")
}
os.Setenv("DB_DATABASE", name)
pc, err := pgxpool.ParseConfig(appDSN())
if err != nil {
t.Fatal(err)
}
tr := &countTracer{}
pc.ConnConfig.Tracer = tr
db, err := pgxpool.NewWithConfig(context.Background(), pc)
if err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
return db, tr
}
// fx builds players straight in the database and names every row it makes (labels), so
// what a claim leaves can be compared across runs.
type fx struct {
t *testing.T
ctx context.Context
db *pgxpool.Pool
run uint32 // the first part of the ids the fixture makes (orders them as made)
ids int
label map[string]string // id → label
res map[string]int // resource name → id
resN []string // resource names, sorted
clock int // seconds after base for the next stake
action int
}
var fxBase = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
func newFx(t *testing.T, db *pgxpool.Pool) *fx {
f := &fx{t: t, ctx: context.Background(), db: db, label: map[string]string{}, res: map[string]int{},
run: rand.Uint32()}
rows, err := db.Query(f.ctx, "select id, name from resource order by name")
f.must(err)
var id int
var n string
_, err = pgx.ForEachRow(rows, []any{&id, &n}, func() error {
f.res[n] = id
f.resN = append(f.resN, n)
return nil
})
f.must(err)
if len(f.resN) < 5 {
t.Fatalf("resources: %v", f.resN)
}
return f
}
func (f *fx) must(err error) {
f.t.Helper()
if err != nil {
f.t.Fatal(err)
}
}
func (f *fx) id(q string, args ...any) string {
f.t.Helper()
var id string
f.must(f.db.QueryRow(f.ctx, q, args...).Scan(&id))
return id
}
func (f *fx) user(label string, onboarding []string, advisors ...string) string {
if onboarding == nil {
onboarding = []string{}
}
u := f.id("insert into users(name, onboarding) values ($1, $2) returning id::text", label, onboarding)
f.label[u] = label
f.t.Cleanup(func() {
f.db.Exec(f.ctx, "delete from event_log where user_id = $1", u)
f.db.Exec(f.ctx, "delete from users where id = $1", u)
})
_, err := f.db.Exec(f.ctx, "insert into bank_account(user_id, balance) values ($1, 1000)", u)
f.must(err)
_, err = f.db.Exec(f.ctx, `insert into resource_account(resource_id, user_id, balance)
select id, $1, 0.1 * id from resource`, u)
f.must(err)
for i, a := range advisors {
_, err = f.db.Exec(f.ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", u, a, i+1)
f.must(err)
}
return u
}
// moon makes a moon with wells of the given resources (indexes into resN) and supplies;
// the wells are labelled <moon>.<i>.
func (f *fx) moon(user, label string, res []int, supply []float64) []string {
m := f.id(`insert into staking_source(name, description, user_id, address, grade)
values ($1, '', $2, '', 'C') returning id::text`, label, user)
f.label[m] = label
var wells []string
for i, r := range res {
f.ids++
w := f.id("insert into resource_well(id, resource_id, source_id, supply) values ($1, $2, $3, $4) returning id::text",
fmt.Sprintf("%08x-0000-4000-8000-%012x", f.run, f.ids), f.res[f.resN[r%len(f.resN)]], m, supply[i])
f.label[w] = fmt.Sprintf("%s.%d", label, i)
wells = append(wells, w)
}
return wells
}
func (f *fx) tool(user, label string, item, yl, cl int) string {
id := f.id("insert into inventory_item(user_id, store_item_id, yield_level, cycle_level) values ($1, $2, $3, $4) returning id::text",
user, item, yl, cl)
f.label[id] = label
return id
}
// stake puts a stake in: ready (started at the next tick of the fixture clock, long ago)
// or still running.
func (f *fx) stake(user, label, tool, well string, amount float64, reach int, ready bool) string {
f.clock++
at, secs := fxBase.Add(time.Duration(f.clock)*time.Second), 30.0
if !ready {
at, secs = time.Now().Add(time.Duration(f.clock)*time.Second), 1e8
}
id := f.id(`insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
values ($1, $2, $3, $4, $5, $6, $7) returning id::text`, user, well, tool, secs, amount, reach, at)
f.label[id] = label
return id
}
// finish makes every running stake of the player ready, in the order of their tools' labels
// (deterministic, unlike the random ids that order stakes started together).
func (f *fx) finish(user string) {
rows, err := f.db.Query(f.ctx, `select id::text, inventory_item_id::text from staking_event
where user_id = $1 and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`, user)
f.must(err)
type st struct{ ID, Tool string }
stakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[st])
f.must(err)
sort.Slice(stakes, func(i, j int) bool { return f.label[stakes[i].Tool] < f.label[stakes[j].Tool] })
for _, s := range stakes {
f.clock++
_, err := f.db.Exec(f.ctx, "update staking_event set created_at = $2, duration_in_secs = least(duration_in_secs, 30) where id = $1",
s.ID, fxBase.Add(time.Duration(f.clock)*time.Second))
f.must(err)
}
}
func fnum(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
// claim runs claimReady and writes the result and the player's whole state to out.
func (f *fx) claim(out *strings.Builder, user string, scope ClaimScope, what string) {
f.action++
var lastLog int64
f.must(f.db.QueryRow(f.ctx, "select coalesce(max(id), 0) from event_log where user_id = $1", user).Scan(&lastLog))
res, err := claimReady(f.ctx, f.db, user, scope)
fmt.Fprintf(out, "== %s %s: %s\n", f.label[user], what, f.lbl(scope.MoonID+scope.WellID))
if err != nil {
fmt.Fprintf(out, "error: %v\n", err)
} else {
var fin []string
for _, m := range res.Finished {
fin = append(fin, f.label[m.ID]+"="+m.Name)
}
fmt.Fprintf(out, "claimed %d redeployed %d total %s stopped %d finished %v\n",
res.Claimed, res.Redeployed, fnum(res.Total), res.Stopped, fin)
}
f.dump(out, user, lastLog)
}
func (f *fx) lbl(id string) string {
if l, ok := f.label[id]; ok {
return l
}
return id
}
func (f *fx) dump(out *strings.Builder, user string, lastLog int64) {
q := func(sql string, args ...any) pgx.Rows {
rows, err := f.db.Query(f.ctx, sql, args...)
f.must(err)
return rows
}
// Name the stakes this action made: tool@action.
rows := q("select id::text, inventory_item_id::text from staking_event where user_id = $1", user)
var id, tool string
_, err := pgx.ForEachRow(rows, []any{&id, &tool}, func() error {
if _, ok := f.label[id]; !ok {
f.label[id] = fmt.Sprintf("%s@%d", f.label[tool], f.action)
}
return nil
})
f.must(err)
var lines []string
rows = q(`select resource_well.id::text, supply from resource_well
join staking_source on staking_source.id = source_id where user_id = $1`, user)
var supply float64
_, err = pgx.ForEachRow(rows, []any{&id, &supply}, func() error {
lines = append(lines, fmt.Sprintf("well %s %s", f.lbl(id), fnum(supply)))
return nil
})
f.must(err)
sort.Strings(lines)
out.WriteString(strings.Join(lines, "\n") + "\n")
lines = nil
rows = q(`select resource.name, balance from resource_account join resource on resource.id = resource_id
where user_id = $1`, user)
var name string
var bal float64
_, err = pgx.ForEachRow(rows, []any{&name, &bal}, func() error {
lines = append(lines, fmt.Sprintf("balance %s %s", name, fnum(bal)))
return nil
})
f.must(err)
sort.Strings(lines)
out.WriteString(strings.Join(lines, "\n") + "\n")
var flags []string
f.must(f.db.QueryRow(f.ctx, "select onboarding from users where id = $1", user).Scan(&flags))
slices.Sort(flags)
fmt.Fprintf(out, "onboarding %v\n", flags)
lines = nil
rows = q(`select staking_event.id::text, well_id::text, inventory_item_id::text, duration_in_secs, stake_amount,
staking_event.reach, claim_event.claim_amount,
coalesce((select string_agg(cw.well_id::text || '=' || cw.amount::text, ',') from claim_well cw
where cw.claim_event_id = claim_event.id), '')
from staking_event left join claim_event on claim_event.staking_event_id = staking_event.id
where staking_event.user_id = $1`, user)
var well string
var dur, amount float64
var reach int
var claim *float64
var cw string
_, err = pgx.ForEachRow(rows, []any{&id, &well, &tool, &dur, &amount, &reach, &claim, &cw}, func() error {
c := "running"
if claim != nil {
c = "claimed " + fnum(*claim)
}
var parts []string
for _, p := range strings.Split(cw, ",") {
if w, a, ok := strings.Cut(p, "="); ok {
parts = append(parts, f.lbl(w)+"="+a)
}
}
slices.Sort(parts)
lines = append(lines, fmt.Sprintf("stake %s well %s tool %s secs %s amount %s reach %d %s %v",
f.lbl(id), f.lbl(well), f.lbl(tool), fnum(dur), fnum(amount), reach, c, parts))
return nil
})
f.must(err)
sort.Strings(lines)
out.WriteString(strings.Join(lines, "\n") + "\n")
// The event log in order; runs of stake_stopped (ordered by start time only, ties
// at random) sorted.
lines = nil
rows = q("select kind, data::text from event_log where user_id = $1 and id > $2 order by id", user, lastLog)
var kind, data string
var run []string
flush := func() {
slices.Sort(run)
lines = append(lines, run...)
run = nil
}
_, err = pgx.ForEachRow(rows, []any{&kind, &data}, func() error {
var m map[string]any
if err := json.Unmarshal([]byte(data), &m); err != nil {
return err
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
var b strings.Builder
for _, k := range keys {
v := m[k]
if s, ok := v.(string); ok {
v = f.lbl(s)
} else if x, ok := v.(float64); ok {
v = fnum(x)
}
fmt.Fprintf(&b, " %s=%v", k, v)
}
line := "log " + kind + b.String()
if kind == "stake_stopped" {
run = append(run, line)
return nil
}
flush()
lines = append(lines, line)
return nil
})
f.must(err)
flush()
if len(lines) > 0 {
out.WriteString(strings.Join(lines, "\n") + "\n")
}
}
// withConfig runs fn with the store items' reach replaced (item id → reach).
func withReach(reach map[int]int, fn func()) {
old := cfg()
c := *old
c.StoreItems = slices.Clone(old.StoreItems)
for i := range c.StoreItems {
if r, ok := reach[c.StoreItems[i].ID]; ok {
c.StoreItems[i].Reach = r
}
}
effective.Store(&c)
defer effective.Store(old)
fn()
}
// seqStakeIDs makes new staking_event ids come from a sequence, in the order the stakes
// are made, whether the database makes them (the column default) or the server
// (newStakeID): stakes started together are ordered by id, which is otherwise random.
func seqStakeIDs(t *testing.T, db *pgxpool.Pool) {
ctx := context.Background()
admin, err := pgxpool.New(ctx, adminDSN())
if err != nil {
t.Fatal(err)
}
defer admin.Close()
const id = `('00000000-0000-4000-8000-' || lpad(to_hex(nextval('claim_test_seq')), 12, '0'))::uuid`
for _, q := range []string{
"create sequence if not exists claim_test_seq",
"grant usage, select on sequence claim_test_seq to " + pgx.Identifier{env("DB_USER", "moonminers")}.Sanitize(),
"alter table staking_event alter column id set default " + id,
} {
if _, err := admin.Exec(ctx, q); err != nil {
t.Fatal(err)
}
}
oldID := newStakeID
newStakeID = func() string {
var s string
if err := db.QueryRow(ctx, "select "+id+"::text").Scan(&s); err != nil {
panic(err)
}
return s
}
t.Cleanup(func() {
newStakeID = oldID
admin, err := pgxpool.New(ctx, adminDSN())
if err != nil {
t.Error(err)
return
}
defer admin.Close()
if _, err := admin.Exec(ctx, "alter table staking_event alter column id set default gen_random_uuid()"); err != nil {
t.Error(err)
}
})
}
func TestClaimGolden(t *testing.T) {
db, _ := testDB(t)
seqStakeIDs(t, db)
ctx := context.Background()
// Live events for the redeployed stakes' amounts and durations; removed at the end.
var evs []int
for _, e := range []struct {
kind, res string
mult float64
}{{"cycle_multiplier", "all", 1.5}, {"yield_multiplier", "Sollux", 1.25}, {"yield_multiplier", "Azurium", 0.8}} {
var id int
err := db.QueryRow(ctx, `insert into live_event(name, kind, params, starts_at, ends_at)
values ('claim test', $1, $2, now() - interval '1 day', now() + interval '1 day') returning id`,
e.kind, map[string]any{"resource": e.res, "multiplier": e.mult}).Scan(&id)
if err != nil {
t.Fatal(err)
}
evs = append(evs, id)
}
defer db.Exec(ctx, "delete from live_event where id = any($1)", evs)
var out strings.Builder
run := func(cfgName string) {
f := newFx(t, db)
claimScenarios(f, &out, cfgName)
}
run("reach5")
withReach(map[int]int{1: 1, 2: 1, 3: 3, 4: 2}, func() { run("mixed") })
withReach(map[int]int{1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1}, func() { run("reach1") })
got := out.String()
const path = "testdata/claim_golden.txt"
if os.Getenv("MM_CLAIM_GOLDEN_WRITE") == "1" {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if got != string(want) {
os.WriteFile(os.TempDir()+"/claim_golden.got", []byte(got), 0o644)
gl, wl := strings.Split(got, "\n"), strings.Split(string(want), "\n")
for i := 0; i < min(len(gl), len(wl)); i++ {
if gl[i] != wl[i] {
t.Fatalf("differs at line %d (full output in %s/claim_golden.got):\n got: %s\nwant: %s",
i+1, os.TempDir(), gl[i], wl[i])
}
}
t.Fatalf("differs in length: got %d lines, want %d", len(gl), len(wl))
}
}
// claimScenarios plays every scenario for one config; p prefixes the labels.
func claimScenarios(f *fx, out *strings.Builder, p string) {
all := ClaimScope{}
redeploy := ClaimScope{Redeploy: true}
// 1. One-well drills overdrilling a well: the third claim empties it and stops the
// fourth (ready, later in the list) and a running one; a multi-well drill on the
// emptied well; claim-all with redeploy, then the redeployed drills again.
u := f.user(p+"-overdrill", nil)
w := f.moon(u, p+"-m1", []int{3, 0, 4}, []float64{10, 50, 5})
for i, item := range []int{1, 2, 1, 3} {
f.stake(u, fmt.Sprintf("%s-s%d", p, i), f.tool(u, fmt.Sprintf("%s-t%d", p, i), item, 0, 0), w[0], 4, 1, true)
}
f.stake(u, p+"-run", f.tool(u, p+"-t4", 1, 0, 0), w[0], 3, 1, false)
f.stake(u, p+"-wide", f.tool(u, p+"-t5", 5, 1, 0), w[2], 7, 5, true)
f.stake(u, p+"-az", f.tool(u, p+"-t6", 2, 0, 1), w[1], 9, 1, true)
f.claim(out, u, redeploy, "claim-all redeploy")
f.finish(u)
f.claim(out, u, redeploy, "claim-all redeploy 2")
f.finish(u)
f.claim(out, u, all, "claim-all")
// 2. Multi-well drills on a 7-well moon (reach 5 covers part of it) and a 4-well moon,
// wells emptied by multi-well claims stopping one-well drills; a moon that finishes.
u = f.user(p+"-wide", []string{"market"}, "Ada Voss", "Rook Halvorsen")
w7 := f.moon(u, p+"-m7", []int{0, 1, 2, 3, 4, 0, 1}, []float64{3, 8, 0, 12, 2.5, 6, 1})
w4 := f.moon(u, p+"-m4", []int{4, 3, 2, 1}, []float64{2, 2, 1, 0.5})
n := 0
tl := func(item, yl, cl int) string {
n++
return f.tool(u, fmt.Sprintf("%s-w%d", p, n), item, yl, cl)
}
f.stake(u, p+"-a1", tl(4, 0, 0), w7[0], 9, 5, true)
f.stake(u, p+"-a2", tl(1, 0, 0), w7[3], 5, 1, true)
f.stake(u, p+"-a3", tl(5, 2, 1), w7[6], 6, 5, true)
f.stake(u, p+"-a4", tl(1, 1, 0), w7[1], 2, 1, true)
f.stake(u, p+"-a5", tl(3, 0, 0), w7[2], 4, 3, true)
f.stake(u, p+"-a6", tl(1, 0, 0), w7[4], 3, 1, false)
f.stake(u, p+"-a7", tl(6, 0, 0), w7[5], 20, 5, false)
f.stake(u, p+"-b1", tl(2, 0, 0), w4[0], 1.5, 1, true)
f.stake(u, p+"-b2", tl(7, 0, 0), w4[1], 3, 5, true)
f.stake(u, p+"-b3", tl(1, 0, 0), w4[3], 1, 1, true)
f.stake(u, p+"-b4", tl(4, 0, 0), w4[2], 2, 3, true)
m7 := f.moonOf(w7[0])
f.claim(out, u, ClaimScope{MoonID: m7}, "claim-moon m7")
f.claim(out, u, ClaimScope{MoonID: m7, Redeploy: true}, "claim-moon m7 redeploy")
f.claim(out, u, ClaimScope{MoonID: f.moonOf(w4[0]), Wide: true, Redeploy: true}, "claim-wide m4 redeploy")
f.claim(out, u, ClaimScope{WellID: w4[0], Redeploy: true}, "claim-well m4.0 redeploy")
f.claim(out, u, ClaimScope{WellID: w4[3]}, "claim-well m4.3")
f.finish(u)
f.claim(out, u, redeploy, "claim-all redeploy")
f.finish(u)
f.claim(out, u, redeploy, "claim-all redeploy 2")
f.finish(u)
f.claim(out, u, all, "claim-all")
f.claim(out, u, all, "claim-all nothing")
// 3. A tool with a second unclaimed stake: redeploying it fails the whole claim.
u = f.user(p+"-busy", []string{"all"})
w = f.moon(u, p+"-mb", []int{0, 1}, []float64{20, 20})
t := f.tool(u, p+"-bt", 1, 0, 0)
f.stake(u, p+"-bs1", t, w[0], 2, 1, true)
f.stake(u, p+"-bs2", t, w[1], 2, 1, false)
f.stake(u, p+"-bs3", f.tool(u, p+"-bt2", 2, 0, 0), w[1], 2, 1, true)
f.claim(out, u, redeploy, "claim-all redeploy busy")
f.claim(out, u, all, "claim-all busy")
// 4. Many moons and drills with tight wells, seeded: three rounds of claim-all with
// redeploy, then one without.
u = f.user(p+"-many", nil, "Juno Park")
rnd := rand.New(rand.NewSource(7))
var moons [][]string
for m := 0; m < 25; m++ {
k := 1 + rnd.Intn(7)
res, sup := make([]int, k), make([]float64, k)
for i := range res {
res[i] = rnd.Intn(5)
sup[i] = float64(rnd.Intn(40)) / 2
if rnd.Intn(6) == 0 {
sup[i] = 0
}
}
moons = append(moons, f.moon(u, fmt.Sprintf("%s-n%d", p, m), res, sup))
}
for i := 0; i < 120; i++ {
ws := moons[rnd.Intn(len(moons))]
item := 1 + rnd.Intn(7)
reach := 1
if rnd.Intn(2) == 0 {
reach = []int{2, 3, 5}[rnd.Intn(3)]
}
tool := f.tool(u, fmt.Sprintf("%s-x%03d", p, i), item, rnd.Intn(3), rnd.Intn(3))
f.stake(u, fmt.Sprintf("%s-y%03d", p, i), tool, ws[rnd.Intn(len(ws))], float64(1+rnd.Intn(16))/2, reach, rnd.Intn(5) != 0)
}
for r := 0; r < 3; r++ {
f.claim(out, u, redeploy, fmt.Sprintf("claim-all redeploy round %d", r))
f.finish(u)
}
f.claim(out, u, all, "claim-all final")
}
func (f *fx) moonOf(well string) string {
return f.id("select source_id::text from resource_well where id = $1", well)
}
// TestClaimAllPerf times claim-all for players with n ready drills, and counts its
// statements: one whole-moon drill per moon of three wells, or (crowded) n drills on 25
// moons, a third of them one-well drills, more than the wells hold.
func TestClaimAllPerf(t *testing.T) {
db, tr := testDB(t)
f := newFx(t, db)
type perfRun struct {
n int
redeploy bool
crowded bool
}
runs := []perfRun{{50, false, false}, {50, true, false}, {200, false, false}, {200, true, false},
{900, false, false}, {900, true, false}, {900, false, true}, {900, true, true}}
for _, run := range runs {
n, moons, supply := run.n, run.n, []float64{40, 30, 3}
if run.crowded {
moons, supply = 25, []float64{300, 200, 8}
}
u := f.user(fmt.Sprintf("perf-%d-%v-%v", n, run.redeploy, run.crowded), nil)
for m := 0; m < moons; m++ {
f.moon(u, fmt.Sprintf("p%d-%d", n, m), []int{m, m + 1, m + 2}, supply)
}
_, err := db.Exec(f.ctx, `
insert into inventory_item(user_id, store_item_id) select $1, 1 + (g % 7) from generate_series(1, $2) g`, u, n)
f.must(err)
_, err = db.Exec(f.ctx, `
with tools as (select id, row_number() over (order by id) r from inventory_item where user_id = $1),
moons as (select id, row_number() over (order by name) r from staking_source where user_id = $1)
insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
select $1, (select id from resource_well where source_id = moons.id order by id offset tools.r % 3 limit 1),
tools.id, 10, 5 + tools.r % 4, case when $4 and tools.r % 3 = 0 then 1 else 5 end,
$2::timestamptz + tools.r * interval '1 second'
from tools join moons on moons.r = (tools.r - 1) % $3 + 1`, u, fxBase, moons, run.crowded)
f.must(err)
tr.mu.Lock()
tr.slow = nil
tr.mu.Unlock()
start, n0 := time.Now(), tr.n.Load()
res, err := claimReady(f.ctx, db, u, ClaimScope{Redeploy: run.redeploy})
f.must(err)
t.Logf("drills %d redeploy %v crowded %v: claimed %d redeployed %d stopped %d in %v, %d statements",
n, run.redeploy, run.crowded, res.Claimed, res.Redeployed, res.Stopped,
time.Since(start).Round(time.Millisecond), tr.n.Load()-n0)
for _, s := range tr.slow {
t.Logf(" slow: %.200s", s)
}
}
}
+645 -45
View File
@@ -9,12 +9,14 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"maps" "maps"
"math" "math"
mrand "math/rand/v2" mrand "math/rand/v2"
"slices" "slices"
"sync"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -138,6 +140,8 @@ type State struct {
// Onboarding flags (onboarding.go): which parts of the game are revealed. // Onboarding flags (onboarding.go): which parts of the game are revealed.
Onboarding []string Onboarding []string
stakes *stakeIndex // s.Stakes by tool and by moon (stakeIndex)
} }
// MoonGrade is the grade a buy without one gets: the grade of the player's newest moon, // MoonGrade is the grade a buy without one gets: the grade of the player's newest moon,
@@ -165,17 +169,79 @@ func (s *State) StoreItem(id int) *StoreItem {
} }
func (s *State) StakeForTool(toolID string) *Stake { func (s *State) StakeForTool(toolID string) *Stake {
for i := range s.Stakes { if i, ok := s.stakeIndex().byTool[toolID]; ok {
if s.Stakes[i].ToolID == toolID {
return &s.Stakes[i] return &s.Stakes[i]
} }
}
return nil return nil
} }
// stakesOn is the running stakes on a moon, in s.Stakes's order (shared: don't change it).
func (s *State) stakesOn(moonID string) []Stake { return s.stakeIndex().byMoon[moonID] }
// stakeIndex groups the stakes by tool and by moon once per state, for the views that
// look them up per tool and per moon. It is rebuilt when s.Stakes changes length (the
// test states append to it after building).
type stakeIndex struct {
n int
first *Stake
byTool map[string]int // the first stake of each tool, its index in s.Stakes
byMoon map[string][]Stake
}
func (s *State) stakeIndex() *stakeIndex {
var first *Stake
if len(s.Stakes) > 0 {
first = &s.Stakes[0]
}
if x := s.stakes; x != nil && x.n == len(s.Stakes) && x.first == first {
return x
}
x := &stakeIndex{n: len(s.Stakes), first: first, byTool: make(map[string]int, len(s.Stakes)),
byMoon: map[string][]Stake{}}
for i, st := range s.Stakes {
if _, ok := x.byTool[st.ToolID]; !ok {
x.byTool[st.ToolID] = i
}
x.byMoon[st.MoonID] = append(x.byMoon[st.MoonID], st)
}
s.stakes = x
return x
}
// ------------------------------------------------------------------ reads // ------------------------------------------------------------------ reads
// configCache is the last loadConfig result and the config snapshot it was read under:
// store_item only changes with the config (syncStoreItems, run by migrate, load-config
// and saveOverride, which puts a new cfg() in effect), so a new cfg() pointer reloads it.
// configTTL also re-reads it now and then, for writes by other processes.
var configCache struct {
sync.Mutex
cfg *GameConfig
at time.Time
c Config
}
const configTTL = 30 * time.Second
// loadConfig returns the resources and store items (cached; the slices are fresh copies).
func loadConfig(ctx context.Context, db *pgxpool.Pool) (Config, error) { func loadConfig(ctx context.Context, db *pgxpool.Pool) (Config, error) {
c := cfg()
configCache.Lock()
cached, ok := configCache.c, configCache.cfg == c && time.Since(configCache.at) < configTTL
configCache.Unlock()
if !ok {
var err error
if cached, err = queryConfig(ctx, db); err != nil {
return cached, err
}
configCache.Lock()
configCache.cfg, configCache.at, configCache.c = c, time.Now(), cached
configCache.Unlock()
}
return Config{Resources: slices.Clone(cached.Resources), StoreItems: slices.Clone(cached.StoreItems)}, nil
}
func queryConfig(ctx context.Context, db *pgxpool.Pool) (Config, error) {
var cfg Config var cfg Config
rows, err := db.Query(ctx, "select name from resource order by name") rows, err := db.Query(ctx, "select name from resource order by name")
@@ -270,7 +336,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
} }
rows, err = db.Query(ctx, ` rows, err = db.Query(ctx, `
select id, name, kind, grade, description, coalesce(created_at, 'epoch'), rating from staking_source select id, name, kind, grade, coalesce(created_at, 'epoch'), rating from staking_source
where user_id = $1 order by created_at, id`, userID) where user_id = $1 order by created_at, id`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -278,7 +344,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
s.Moons, err = pgx.CollectRows(rows, func(row pgx.CollectableRow) (Moon, error) { s.Moons, err = pgx.CollectRows(rows, func(row pgx.CollectableRow) (Moon, error) {
var m Moon var m Moon
var rating *int var rating *int
err := row.Scan(&m.ID, &m.Name, &m.Kind, &m.Grade, &m.Description, &m.CreatedAt, &rating) err := row.Scan(&m.ID, &m.Name, &m.Kind, &m.Grade, &m.CreatedAt, &rating)
if rating != nil { if rating != nil {
r := ratingOf(*rating) r := ratingOf(*rating)
m.Rating = &r m.Rating = &r
@@ -299,14 +365,16 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
moonIndex := make(map[string]int, len(s.Moons))
for i := range s.Moons {
moonIndex[s.Moons[i].ID] = i
}
var moonID string var moonID string
var w Well var w Well
_, err = pgx.ForEachRow(rows, []any{&moonID, &w.ID, &w.Resource, &w.Supply}, func() error { _, err = pgx.ForEachRow(rows, []any{&moonID, &w.ID, &w.Resource, &w.Supply}, func() error {
for i := range s.Moons { if i, ok := moonIndex[moonID]; ok {
if s.Moons[i].ID == moonID {
s.Moons[i].Wells = append(s.Moons[i].Wells, w) s.Moons[i].Wells = append(s.Moons[i].Wells, w)
} }
}
return nil return nil
}) })
if err != nil { if err != nil {
@@ -342,12 +410,17 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
return nil, err return nil, err
} }
// Idle tools and the well they last mined (their latest stake is claimed). // Idle tools and the well they last mined (their latest stake is claimed). Per tool, so
// that it reads one stake per tool (staking_event_tool), not the player's history.
rows, err = db.Query(ctx, ` rows, err = db.Query(ctx, `
select distinct on (inventory_item_id) inventory_item_id, well_id::text select inventory_item.id, last.well_id::text
from staking_event from inventory_item
where user_id = $1 join lateral (
order by inventory_item_id, created_at desc`, userID) select well_id from staking_event
where inventory_item_id = inventory_item.id
order by created_at desc limit 1
) last on true
where inventory_item.user_id = $1`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -445,13 +518,13 @@ func loadPastPage(ctx context.Context, db *pgxpool.Pool, s *State, page int, sel
return nil return nil
} }
rows, err := db.Query(ctx, ` rows, err := db.Query(ctx, `
select ids.id select ids.id::text
from unnest($1::text[]) ids(id) from unnest($1::uuid[]) ids(id)
left join resource_well on resource_well.source_id::text = ids.id left join resource_well on resource_well.source_id = ids.id
left join staking_event on staking_event.well_id = resource_well.id left join staking_event on staking_event.well_id = resource_well.id
left join claim_event on claim_event.staking_event_id = staking_event.id left join claim_event on claim_event.staking_event_id = staking_event.id
group by ids.id group by ids.id
order by max(claim_event.created_at) desc nulls last, ids.id order by max(claim_event.created_at) desc nulls last, ids.id::text
limit $2 offset $3`, ids, pastPerPage, (s.PastPage-1)*pastPerPage) limit $2 offset $3`, ids, pastPerPage, (s.PastPage-1)*pastPerPage)
if err != nil { if err != nil {
return err return err
@@ -463,15 +536,19 @@ func loadPastPage(ctx context.Context, db *pgxpool.Pool, s *State, page int, sel
if past[selected] != nil && !slices.Contains(load, selected) { if past[selected] != nil && !slices.Contains(load, selected) {
load = append(load, selected) load = append(load, selected)
} }
// From the moons' wells to the stakes deployed on them (a claim's wells are all on the
// moon of the stake's well, claimInTx), so that the indexes serve it.
rows, err = db.Query(ctx, ` rows, err = db.Query(ctx, `
select resource_well.source_id, resource.name, select resource_well.source_id, resource.name,
sum(coalesce(claim_well.amount, claim_event.claim_amount)), max(claim_event.created_at) sum(coalesce(claim_well.amount, claim_event.claim_amount)), max(claim_event.created_at)
from claim_event from resource_well anchor
join staking_event on staking_event.id = claim_event.staking_event_id join staking_event on staking_event.well_id = anchor.id
join claim_event on claim_event.staking_event_id = staking_event.id
left join claim_well on claim_well.claim_event_id = claim_event.id left join claim_well on claim_well.claim_event_id = claim_event.id
join resource_well on resource_well.id = coalesce(claim_well.well_id, staking_event.well_id) join resource_well on resource_well.id = coalesce(claim_well.well_id, staking_event.well_id)
join resource on resource.id = resource_well.resource_id join resource on resource.id = resource_well.resource_id
where staking_event.user_id = $1 and resource_well.source_id::text = any($2) where anchor.source_id = any($2::uuid[]) and staking_event.user_id = $1
and resource_well.source_id = any($2::uuid[])
group by resource_well.source_id, resource.name group by resource_well.source_id, resource.name
order by resource.name`, s.UserID, load) order by resource.name`, s.UserID, load)
if err != nil { if err != nil {
@@ -1171,7 +1248,13 @@ type ClaimResult struct {
Stopped int // other drills stopped because the claim emptied their well (stopTapped) Stopped int // other drills stopped because the claim emptied their well (stopTapped)
} }
// claimReady claims the player's finished stakes in scope. // claimReady claims the player's finished stakes in scope, oldest first, each from what
// the earlier ones left, and with Redeploy puts each tool back to work right after its
// claim. It goes by the rules of claimInTx, stopTapped and stakeInTx (which stake, redrill
// and moon sales use), but on a copy of the moons in memory: it locks and reads what it
// needs in a few statements, plays the claims through, then writes everything at once. A
// change to those rules goes here too; testdata/claim_golden.txt (claim_db_test.go) is
// what the database looks like after each scenario.
func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope ClaimScope) (ClaimResult, error) { func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope ClaimScope) (ClaimResult, error) {
var res ClaimResult var res ClaimResult
err := inTx(ctx, db, func(tx pgx.Tx) error { err := inTx(ctx, db, func(tx pgx.Tx) error {
@@ -1184,8 +1267,6 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
and (not $4 or staking_event.reach <> 1) and (not $4 or staking_event.reach <> 1)
and staking_event.created_at + duration_in_secs * interval '1 second' <= now() and staking_event.created_at + duration_in_secs * interval '1 second' <= now()
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)` and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`
// Lock the wells of the moons claimed on first, in id order: a claim may take from
// every well of its moon, and moon sales lock them the same way (resale.go).
rows, err := tx.Query(ctx, "select distinct resource_well.source_id::text"+inScope, rows, err := tx.Query(ctx, "select distinct resource_well.source_id::text"+inScope,
userID, scope.MoonID, scope.WellID, scope.Wide) userID, scope.MoonID, scope.WellID, scope.Wide)
if err != nil { if err != nil {
@@ -1195,11 +1276,44 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
if err != nil { if err != nil {
return err return err
} }
_, err = tx.Exec(ctx, ` // Lock the wells of the moons claimed on first, in id order: a claim may take from
select 1 from resource_well where source_id::text = any($1) order by id for update`, moonIDs) // every well of its moon, and moon sales lock them the same way (resale.go).
c := &claimer{userID: userID, moons: map[string]*claimMoon{}, wells: map[string]*claimWell{},
stakes: map[string]*claimStake{}, accounts: map[int][]*claimAccount{}}
rows, err = tx.Query(ctx, `
select resource_well.id, resource_well.source_id, staking_source.user_id, staking_source.name,
resource_well.resource_id, resource.name, resource_well.supply
from resource_well
join staking_source on staking_source.id = resource_well.source_id
join resource on resource.id = resource_well.resource_id
where resource_well.source_id::text = any($1)
order by resource_well.id
for update of resource_well`, moonIDs)
if err != nil { if err != nil {
return err return err
} }
var w claimWell
var owner, name string
_, err = pgx.ForEachRow(rows, []any{&w.ID, &w.MoonID, &owner, &name, &w.ResourceID, &w.Resource, &w.Supply}, func() error {
m := c.moons[w.MoonID]
if m == nil {
m = &claimMoon{ID: w.MoonID, Owner: owner, Name: name}
c.moons[w.MoonID] = m
}
cw := w
m.Wells = append(m.Wells, &cw)
c.wells[cw.ID] = &cw
return nil
})
if err != nil {
return err
}
for _, m := range c.moons {
// The moon's order (loadState's): by resource, then id.
slices.SortStableFunc(m.Wells, func(a, b *claimWell) int {
return cmp.Or(cmp.Compare(a.Resource, b.Resource), cmp.Compare(a.ID, b.ID))
})
}
rows, err = tx.Query(ctx, ` rows, err = tx.Query(ctx, `
select staking_event.id, staking_event.inventory_item_id, staking_event.well_id, select staking_event.id, staking_event.inventory_item_id, staking_event.well_id,
resource_well.source_id`+inScope+` resource_well.source_id`+inScope+`
@@ -1211,24 +1325,44 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
return err return err
} }
type ready struct{ ID, ToolID, WellID, MoonID string } type ready struct{ ID, ToolID, WellID, MoonID string }
stakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[ready]) readyStakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[ready])
if err != nil { if err != nil {
return err return err
} }
// Lock the balances up front, in a fixed order, so concurrent claims can't deadlock. // Lock the balances up front, in a fixed order, so concurrent claims can't deadlock.
_, err = tx.Exec(ctx, ` rows, err = tx.Query(ctx, `
select 1 from resource_account where user_id = $1 order by resource_id for update`, userID) select id, resource_id, balance from resource_account where user_id = $1
order by resource_id for update`, userID)
if err != nil { if err != nil {
return err return err
} }
var moons []string // the moons claimed on, once each var a claimAccount
for _, st := range stakes { var rid int
amount, stopped, err := claimInTx(ctx, tx, userID, st.ID) _, err = pgx.ForEachRow(rows, []any{&a.ID, &rid, &a.Balance}, func() error {
if err == errAlreadyClaimed { ca := a
continue // claimed by a concurrent request c.accounts[rid] = append(c.accounts[rid], &ca)
} else if err != nil { return nil
})
if err != nil {
return err return err
} }
if len(readyStakes) == 0 {
return nil
}
readyIDs, toolIDs := make([]string, len(readyStakes)), make([]string, len(readyStakes))
for i, st := range readyStakes {
readyIDs[i], toolIDs[i] = st.ID, st.ToolID
}
if err := c.load(ctx, tx, moonIDs, readyIDs, toolIDs, scope.Redeploy); err != nil {
return err
}
var moons []string // the moons claimed on, once each
for _, st := range readyStakes {
amount, stopped, ok := c.claim(st.ID)
if !ok {
continue // claimed by a concurrent request, or stopped by an earlier claim
}
if !slices.Contains(moons, st.MoonID) { if !slices.Contains(moons, st.MoonID) {
moons = append(moons, st.MoonID) moons = append(moons, st.MoonID)
} }
@@ -1237,7 +1371,7 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
res.Stopped += stopped res.Stopped += stopped
if scope.Redeploy { if scope.Redeploy {
// A drained well keeps its tools idle instead of failing the whole claim. // A drained well keeps its tools idle instead of failing the whole claim.
switch err := stakeInTx(ctx, tx, userID, st.ToolID, st.WellID); err { switch err := c.stake(st.ToolID, st.WellID); err {
case nil: case nil:
res.Redeployed++ res.Redeployed++
case errWellEmpty, errWellCommitted: case errWellEmpty, errWellCommitted:
@@ -1247,25 +1381,479 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
} }
} }
for _, id := range moons { for _, id := range moons {
done, err := moonFinished(ctx, tx, id) m := c.moons[id]
if !c.finished(m) {
continue
}
res.Finished = append(res.Finished, Moon{ID: id, Name: m.Name})
c.log("moon_finished", map[string]any{"moon": id, "name": m.Name})
}
return c.write(ctx, tx)
})
return res, err
}
// claimer is claimReady's copy of the moons claimed on: their wells, the stakes on them
// not claimed yet, the player's balances, and what has changed, to be written at the end.
type claimer struct {
userID string
now time.Time
moons map[string]*claimMoon
wells map[string]*claimWell
stakes map[string]*claimStake // the unclaimed stakes on the moons, by id
accounts map[int][]*claimAccount // the player's balances, by resource id
tools map[string]*claimTool // the claimed stakes' tools (with Redeploy)
busy map[string][]string // tool → its unclaimed stakes
flags []string // the player's onboarding flags
newFlags []string // flags added
bonus Bonus // with Redeploy: the advisors
speed float64 // and the live events
yield map[string]float64 // per resource
claims []claimEvent // claim_event rows to insert, in order
split []claimSplit // claim_well rows
added []*claimStake // new staking_event rows
stopped []string // stakes stopped (not already locked)
locked map[string]bool // stakes locked by claimReady's query
events []struct{ Kind, Data string } // event_log rows, in order
}
type claimMoon struct {
ID, Owner, Name string
Wells []*claimWell // in the moon's order
Stakes []*claimStake // unclaimed, by start then id
}
type claimWell struct {
ID, MoonID string
ResourceID int
Resource string
Supply float64
changed bool
}
type claimStake struct {
Stake
UserID string
claimed bool
}
type claimAccount struct {
ID string
Balance float64
changed bool
}
type claimTool struct {
StoreItemID int
Lv Levels
}
type claimEvent struct {
ID, StakeID string
Amount float64
}
type claimSplit struct {
ClaimID, WellID string
Amount float64
}
// load reads the rest of what the claims need, the wells being locked: the stakes on the
// moons, onboarding flags and, with redeploy, the tools (locked), their other stakes, the
// advisors and the live events.
func (c *claimer) load(ctx context.Context, tx pgx.Tx, moonIDs, readyIDs, toolIDs []string, redeploy bool) error {
c.locked = map[string]bool{}
for _, id := range readyIDs {
c.locked[id] = true
}
// Every unclaimed stake on the moons (moonFinished counts them all; the rest only the
// player's), oldest first as loadState orders them.
rows, err := tx.Query(ctx, `
select staking_event.id, resource_well.source_id, well_id, inventory_item_id,
resource.name, stake_amount, staking_event.created_at, duration_in_secs,
staking_event.reach, staking_event.user_id
from staking_event
join resource_well on resource_well.id = well_id
join resource on resource.id = resource_well.resource_id
where resource_well.source_id::text = any($1)
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)
order by staking_event.created_at, staking_event.id`, moonIDs)
if err != nil { if err != nil {
return err return err
} }
if !done { var st claimStake
continue _, err = pgx.ForEachRow(rows, []any{&st.ID, &st.MoonID, &st.WellID, &st.ToolID, &st.Resource, &st.Amount,
} &st.Start, &st.DurationInSecs, &st.Reach, &st.UserID}, func() error {
m := Moon{ID: id} s := st
if err := tx.QueryRow(ctx, "select name from staking_source where id = $1", id).Scan(&m.Name); err != nil { c.stakes[s.ID] = &s
c.moons[s.MoonID].Stakes = append(c.moons[s.MoonID].Stakes, &s)
return nil
})
if err != nil {
return err return err
} }
res.Finished = append(res.Finished, m) err = tx.QueryRow(ctx, "select onboarding, now() from users where id = $1", c.userID).Scan(&c.flags, &c.now)
if err := logEvent(ctx, tx, userID, "moon_finished", map[string]any{"moon": id, "name": m.Name}); err != nil { if err != nil {
return err
}
if !redeploy {
return nil
}
// The tools after the balances, as stakeInTx locks them after claimReady's locks.
rows, err = tx.Query(ctx, `
select inventory_item.id, store_item_id, yield_level, cycle_level from inventory_item
where id = any($1::uuid[]) and user_id = $2
order by id for update`, toolIDs, c.userID)
if err != nil {
return err
}
c.tools = map[string]*claimTool{}
var id string
var t claimTool
_, err = pgx.ForEachRow(rows, []any{&id, &t.StoreItemID, &t.Lv.Yield, &t.Lv.Cycle}, func() error {
ct := t
c.tools[id] = &ct
return nil
})
if err != nil {
return err
}
// A tool is busy while it has an unclaimed stake, on these moons or any other.
rows, err = tx.Query(ctx, `
select inventory_item_id, id from staking_event
where inventory_item_id = any($1::uuid[])
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`,
slices.Collect(maps.Keys(c.tools)))
if err != nil {
return err
}
c.busy = map[string][]string{}
var tool, stake string
_, err = pgx.ForEachRow(rows, []any{&tool, &stake}, func() error {
c.busy[tool] = append(c.busy[tool], stake)
return nil
})
if err != nil {
return err
}
if c.bonus, err = loadBonus(ctx, tx, c.userID); err != nil {
return err
}
if c.speed, err = eventMultiplier(ctx, tx, "cycle_multiplier", "all"); err != nil {
return err
}
// eventMultiplier for every resource at once.
rows, err = tx.Query(ctx, `
select resource.name, (
select coalesce(exp(sum(ln((params->>'multiplier')::float8))), 1)
from live_event
where enabled and kind = 'yield_multiplier' and starts_at <= now() and ends_at > now()
and (params->>'multiplier')::float8 > 0
and coalesce(params->>'resource', 'all') in ('all', resource.name))
from resource`)
if err != nil {
return err
}
c.yield = map[string]float64{}
var res string
var m float64
_, err = pgx.ForEachRow(rows, []any{&res, &m}, func() error {
c.yield[res] = m
return nil
})
return err
}
func (c *claimer) log(kind string, data map[string]any) {
b, err := json.Marshal(data)
if err != nil {
panic(err) // maps of strings and numbers
}
c.events = append(c.events, struct{ Kind, Data string }{kind, string(b)})
}
// unlock is onboarding's unlock on the copy of the flags.
func (c *claimer) unlock(flags ...string) {
if slices.Contains(c.flags, "all") {
return
}
for _, f := range flags {
if !slices.Contains(c.flags, f) {
c.flags = append(c.flags, f)
c.newFlags = append(c.newFlags, f)
}
}
}
// claim is claimInTx: it claims a finished stake, and returns what it got, how many other
// stakes it stopped (stopTapped) by emptying a well, and false if it was already claimed.
func (c *claimer) claim(stakeID string) (float64, int, bool) {
st := c.stakes[stakeID]
if st == nil || st.claimed {
return 0, 0, false
}
m := c.moons[st.MoonID]
// The wells it mines: its own, or (reach > 1) its moon's. A well can't give more than
// it has left, so neither can the moon. (Yield events were applied at stake time.)
wells := m.Wells
if st.Reach <= 1 {
wells = []*claimWell{c.wells[st.WellID]}
}
supply := make([]float64, len(wells))
anchor := -1
for i, w := range wells {
supply[i] = w.Supply
if w.ID == st.WellID {
anchor = i
}
}
take := splitClaim(st.Amount, anchor, supply, st.Reach)
total := 0.0
for _, t := range take {
total += t
}
claimID := randomUUID()
c.claims = append(c.claims, claimEvent{claimID, st.ID, total})
st.claimed = true
for i, w := range wells {
if take[i] <= 0 {
continue
}
for _, a := range c.accounts[w.ResourceID] {
a.Balance += take[i]
a.changed = true
}
w.Supply -= take[i]
w.changed = true
// What a multi-well claim took from each well (loadPastMoons).
if st.Reach > 1 {
c.split = append(c.split, claimSplit{claimID, w.ID, take[i]})
}
c.unlock("market", "claimed:"+w.Resource)
c.log("claim", map[string]any{"resource": w.Resource, "amount": take[i]})
}
// A claim that empties a well stops the drills it leaves with nothing to mine.
for i := range wells {
if take[i] > 0 && supply[i]-take[i] <= 0 {
return total, c.stopTapped(m), true
}
}
return total, 0, true
}
// stopTapped ends the player's unclaimed stakes on the moon that have nothing left to
// mine: those on an empty well and, once the whole moon is empty, the multi-well ones
// (stopTapped in the database).
func (c *claimer) stopTapped(m *claimMoon) int {
empty := !slices.ContainsFunc(m.Wells, func(w *claimWell) bool { return w.Supply > 0 })
n := 0
for _, st := range m.Stakes {
if st.claimed || st.UserID != c.userID {
continue
}
if st.Reach > 1 && !empty || st.Reach <= 1 && c.wells[st.WellID].Supply > 0 {
continue
}
st.claimed = true
c.claims = append(c.claims, claimEvent{randomUUID(), st.ID, 0})
if !c.locked[st.ID] {
c.stopped = append(c.stopped, st.ID)
}
c.log("stake_stopped", map[string]any{"stake": st.ID, "tool": st.ToolID, "well": st.WellID, "reason": "tapped_out"})
n++
}
return n
}
// stake is stakeInTx for a tool whose stake was just claimed (redeploy).
func (c *claimer) stake(toolID, wellID string) error {
t := c.tools[toolID]
if t == nil {
return gameErr("Tool not found")
}
item := configItem(t.StoreItemID)
if item == nil {
return gameErr("Tool not found")
}
// A tool is busy until its stake is claimed.
for _, id := range c.busy[toolID] {
if st := c.stakes[id]; st == nil || !st.claimed {
return gameErr("That tool is already mining")
}
}
w := c.wells[wellID]
if w == nil || c.moons[w.MoonID].Owner != c.userID {
return gameErr("Well not found")
}
m := c.moons[w.MoonID]
// A tool that mines several wells (reach) is tapped out only when the whole moon is:
// on an empty well it goes to the moon's next one with supply (as coverWells walks them).
reach := item.ToolReach()
if w.Supply <= 0 && reach > 1 {
at := slices.Index(m.Wells, w)
for k := 1; k <= len(m.Wells); k++ {
if x := m.Wells[(max(at, 0)+k)%len(m.Wells)]; x.Supply > 0 {
w = x
break
}
}
}
if w.Supply <= 0 {
return errWellEmpty
}
// Advisors and live events count for the stakes started while they apply.
duration, amount := drillStats(item.ToolSecs(t.Lv), item.ToolYield(t.Lv), c.bonus, c.speed, c.yield[w.Resource])
// The drills already on the well (and multi-well ones reaching it) will take all it
// has left: this one would get nothing.
moon := &Moon{ID: m.ID, Wells: make([]Well, len(m.Wells))}
for i, x := range m.Wells {
moon.Wells[i] = Well{ID: x.ID, Resource: x.Resource, Supply: x.Supply}
}
var stakes []Stake
for _, st := range m.Stakes {
if !st.claimed && st.UserID == c.userID {
stakes = append(stakes, st.Stake)
}
}
if newStakeTake(moon, stakes, w.ID, amount, reach) <= 1e-9 {
return errWellCommitted
}
st := &claimStake{UserID: c.userID, Stake: Stake{ID: newStakeID(), MoonID: m.ID, WellID: w.ID, ToolID: toolID,
Resource: w.Resource, Amount: amount, Start: c.now, DurationInSecs: duration, Reach: reach}}
// In its place among the moon's stakes: by start, then id (as the database orders them).
i, _ := slices.BinarySearchFunc(m.Stakes, st, func(a, b *claimStake) int {
return cmp.Or(a.Start.Compare(b.Start), cmp.Compare(a.ID, b.ID))
})
m.Stakes = slices.Insert(m.Stakes, i, st)
c.stakes[st.ID] = st
c.busy[toolID] = append(c.busy[toolID], st.ID)
c.added = append(c.added, st)
c.unlock("equipment")
c.log("stake", map[string]any{"tool": toolID, "well": w.ID, "seconds": duration})
return nil
}
// finished is moonFinished: the moon's wells are empty and nothing on it is left to claim.
func (c *claimer) finished(m *claimMoon) bool {
return !slices.ContainsFunc(m.Wells, func(w *claimWell) bool { return w.Supply > 0 }) &&
!slices.ContainsFunc(m.Stakes, func(st *claimStake) bool { return !st.claimed })
}
// write saves what the claims changed: a statement per table.
func (c *claimer) write(ctx context.Context, tx pgx.Tx) error {
if len(c.stopped) > 0 {
// Locked as stopTapped locks them.
_, err := tx.Exec(ctx, "select 1 from staking_event where id = any($1::uuid[]) order by id for update", c.stopped)
if err != nil {
return err
}
}
if len(c.added) > 0 {
var ids, wells, tools []string
var secs, amounts []float64
var reach []int32
for _, st := range c.added {
ids, wells, tools = append(ids, st.ID), append(wells, st.WellID), append(tools, st.ToolID)
secs, amounts, reach = append(secs, st.DurationInSecs), append(amounts, st.Amount), append(reach, int32(st.Reach))
}
_, err := tx.Exec(ctx, `
insert into staking_event(id, user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach)
select id, $1, well, tool, secs, amount, reach
from unnest($2::uuid[], $3::uuid[], $4::uuid[], $5::float8[], $6::float8[], $7::int[])
with ordinality as t(id, well, tool, secs, amount, reach, n)
order by n`, c.userID, ids, wells, tools, secs, amounts, reach)
if err != nil {
return err
}
}
if len(c.claims) > 0 {
var ids, stakes []string
var amounts []float64
for _, e := range c.claims {
ids, stakes, amounts = append(ids, e.ID), append(stakes, e.StakeID), append(amounts, e.Amount)
}
_, err := tx.Exec(ctx, `
insert into claim_event(id, staking_event_id, claim_amount)
select id, stake, amount from unnest($1::uuid[], $2::uuid[], $3::float8[])
with ordinality as t(id, stake, amount, n)
order by n`, ids, stakes, amounts)
if err != nil {
return err
}
}
if len(c.split) > 0 {
var claims, wells []string
var amounts []float64
for _, s := range c.split {
claims, wells, amounts = append(claims, s.ClaimID), append(wells, s.WellID), append(amounts, s.Amount)
}
_, err := tx.Exec(ctx, `
insert into claim_well(claim_event_id, well_id, amount)
select * from unnest($1::uuid[], $2::uuid[], $3::float8[])`, claims, wells, amounts)
if err != nil {
return err
}
}
// New values, not differences: they were worked out claim by claim, as the database
// would have added them up.
var wellIDs []string
var supply []float64
for _, w := range c.wells {
if w.changed {
wellIDs, supply = append(wellIDs, w.ID), append(supply, w.Supply)
}
}
if len(wellIDs) > 0 {
_, err := tx.Exec(ctx, `
update resource_well set supply = t.supply
from unnest($1::uuid[], $2::float8[]) as t(id, supply)
where resource_well.id = t.id`, wellIDs, supply)
if err != nil {
return err
}
}
var accIDs []string
var balances []float64
for _, as := range c.accounts {
for _, a := range as {
if a.changed {
accIDs, balances = append(accIDs, a.ID), append(balances, a.Balance)
}
}
}
if len(accIDs) > 0 {
_, err := tx.Exec(ctx, `
update resource_account set balance = t.balance
from unnest($1::uuid[], $2::float8[]) as t(id, balance)
where resource_account.id = t.id`, accIDs, balances)
if err != nil {
return err
}
}
if len(c.newFlags) > 0 {
_, err := tx.Exec(ctx, `
update users set onboarding = array(select distinct unnest(onboarding || $2::text[]))
where id = $1`, c.userID, c.newFlags)
if err != nil {
return err
}
}
if len(c.events) > 0 {
kinds, data := make([]string, len(c.events)), make([]string, len(c.events))
for i, e := range c.events {
kinds[i], data[i] = e.Kind, e.Data
}
_, err := tx.Exec(ctx, `
insert into event_log(user_id, kind, data)
select $1, kind, data from unnest($2::text[], $3::jsonb[]) with ordinality as t(kind, data, n)
order by n`, c.userID, kinds, data)
if err != nil {
return err return err
} }
} }
return nil return nil
})
return res, err
} }
// redrill puts the idle tools whose last stake was on a well back to work on it, picked // redrill puts the idle tools whose last stake was on a well back to work on it, picked
@@ -1721,3 +2309,15 @@ func clearUserData(ctx context.Context, db *pgxpool.Pool, userID string) error {
return giveStartingTools(ctx, tx, userID) return giveStartingTools(ctx, tx, userID)
}) })
} }
// newStakeID is the id of a new staking_event (claimReady): random, as the column's default.
var newStakeID = randomUUID
// randomUUID is a random (version 4) uuid.
func randomUUID() string {
var b [16]byte
rand.Read(b[:])
b[6] = b[6]&0x0f | 0x40
b[8] = b[8]&0x3f | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
+89 -96
View File
@@ -15,6 +15,8 @@ import (
"math" "math"
"strconv" "strconv"
"strings" "strings"
"sync"
"sync/atomic"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -73,9 +75,84 @@ func floorDiv(a, b int64) int64 {
return q return q
} }
// marketRateAt is the market price of a unit at a tick, before live events. // marketRateAt is the market price of a unit at a tick, before live events. Ticks in the
// cached 24h window (marketSeries) are read from it; the current tick fills it.
func marketRateAt(name string, tick int64) float64 { func marketRateAt(name string, tick int64) float64 {
return cfg().unitPrice(name) * (1 + cfg().MarketVolatility*marketDrift(name, tick)) c := cfg()
if mc := marketCached.Load(); mc != nil && mc.cfg == c && tick <= mc.tick && tick > mc.tick-marketPoints {
return mc.get(name).series[marketPoints-1-int(mc.tick-tick)]
}
if d := tick - marketTick(time.Now()); d >= -1 && d <= 1 {
return marketSeriesAt(name, tick).rate
}
return rateAt(c, name, tick)
}
func rateAt(c *GameConfig, name string, tick int64) float64 {
return c.unitPrice(name) * (1 + c.MarketVolatility*marketDrift(name, tick))
}
// ------------------------------------------------------------------ shared 24h series
// marketSeries is an item's market rate over the 24h up to a tick (marketPoints, oldest
// first) and what the market shows of it. It's shared by every player: read it, never
// modify it. Live price events aren't in it (they multiply the quote per request), so
// they never make it stale.
type marketSeries struct {
series []float64
rate, open, high, low, change float64
}
// marketTickCache holds the series of one tick under one config snapshot (cfg() is a new
// pointer whenever the economy editor or another instance changes the config).
type marketTickCache struct {
cfg *GameConfig
tick int64
items sync.Map // name → *marketSeries
}
var marketCached atomic.Pointer[marketTickCache]
func (mc *marketTickCache) get(name string) *marketSeries {
if v, ok := mc.items.Load(name); ok {
return v.(*marketSeries)
}
v, _ := mc.items.LoadOrStore(name, newMarketSeries(mc.cfg, name, mc.tick))
return v.(*marketSeries)
}
// marketSeriesAt is an item's series up to tick, cached for the newest tick asked for.
func marketSeriesAt(name string, tick int64) *marketSeries {
c := cfg()
mc := marketCached.Load()
if mc == nil || mc.cfg != c || mc.tick < tick {
next := &marketTickCache{cfg: c, tick: tick}
if marketCached.CompareAndSwap(mc, next) {
mc = next
} else {
mc = marketCached.Load()
}
}
if mc == nil || mc.cfg != c || mc.tick != tick {
return newMarketSeries(c, name, tick) // an older tick, or a racing config change
}
return mc.get(name)
}
func newMarketSeries(c *GameConfig, name string, tick int64) *marketSeries {
m := &marketSeries{series: make([]float64, marketPoints)}
for i := range m.series {
m.series[i] = rateAt(c, name, tick-int64(marketPoints-1-i))
}
m.rate, m.open = m.series[marketPoints-1], m.series[0]
m.high, m.low = m.series[0], m.series[0]
for _, p := range m.series {
m.high, m.low = max(m.high, p), min(m.low, p)
}
if m.open > 0 {
m.change = (m.rate/m.open - 1) * 100
}
return m
} }
// marketRate is the market price of a unit at time t, before live events. // marketRate is the market price of a unit at time t, before live events.
@@ -250,10 +327,9 @@ type Quote struct {
High float64 // 24h high/low of the market rate High float64 // 24h high/low of the market rate
Low float64 Low float64
Value float64 // Balance × Price (at the quote) Value float64 // Balance × Price (at the quote)
Spark string // SVG path, viewBox 0 0 100 32
URL string URL string
Selected bool Selected bool
series []float64 series []float64 // the shared 24h market rate (marketSeries): read only
} }
func (q Quote) Up() bool { return q.Change >= 0 } func (q Quote) Up() bool { return q.Change >= 0 }
@@ -311,34 +387,12 @@ func marketSymbol(name string) string {
return strings.ToUpper(name) return strings.ToUpper(name)
} }
// MarketChart is the selected quote's 24h chart, viewBox 0 0 ChartW ChartH. // MarketChart is the selected quote's 24h chart: its points (the quote's series) start at
// Start, unix ms, Step ms apart.
type MarketChart struct { type MarketChart struct {
Line, Area string Start, Step int64
Prices string // comma-separated, for the crosshair
Start, Step int64 // unix ms of the first point, ms between points
HighY, LowY float64
Grid []GridLine
Times []TimeLabel
} }
type GridLine struct {
Y float64 // in viewBox units
Top float64 // the same, as a percent of the height
Label string
}
type TimeLabel struct {
X float64 // percent
Label string
}
const (
chartW = 600
chartH = 220
sparkW = 100
sparkH = 32
)
// marketPage builds the market from the rows the market section lists (resources and // marketPage builds the market from the rows the market section lists (resources and
// refined items). // refined items).
func (s *State) marketPage(rows []MarketRow, v View) *MarketPage { func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
@@ -347,23 +401,13 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
for _, row := range rows { for _, row := range rows {
q := Quote{MarketRow: row, Boost: s.PriceBoosts.For(row.Resource), Advisor: s.Bonus().Sell, q := Quote{MarketRow: row, Boost: s.PriceBoosts.For(row.Resource), Advisor: s.Bonus().Sell,
URL: View{Section: "market", Moon: v.Moon, Pick: row.Resource}.URL()} URL: View{Section: "market", Moon: v.Moon, Pick: row.Resource}.URL()}
q.series = make([]float64, marketPoints) m := marketSeriesAt(row.Resource, now)
for i := range q.series { q.series = m.series
q.series[i] = marketRateAt(row.Resource, now-int64(marketPoints-1-i)) q.Rate, q.Open, q.High, q.Low, q.Change = m.rate, m.open, m.high, m.low, m.change
}
q.Rate, q.Open = q.series[marketPoints-1], q.series[0]
q.High, q.Low = q.series[0], q.series[0]
for _, p := range q.series {
q.High, q.Low = max(q.High, p), min(q.Low, p)
}
if q.Open > 0 {
q.Change = (q.Rate/q.Open - 1) * 100
}
q.Pressure = s.Pressure[row.Resource] q.Pressure = s.Pressure[row.Resource]
q.Factor = cfg().MarketPressure.Factor(q.Pressure) q.Factor = cfg().MarketPressure.Factor(q.Pressure)
q.Price = q.Rate * q.Boost q.Price = q.Rate * q.Boost
q.Value = q.Balance * q.Price q.Value = q.Balance * q.Price
q.Spark, _ = chartPaths(q.series, 3, sparkW, sparkH, q.Low, q.High)
mp.Quotes = append(mp.Quotes, q) mp.Quotes = append(mp.Quotes, q)
} }
for i := range mp.Quotes { for i := range mp.Quotes {
@@ -387,62 +431,11 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
} }
if q := mp.Selected; q != nil { if q := mp.Selected; q != nil {
q.Selected = true q.Selected = true
mp.Chart = s.marketChart(q, now) mp.Chart = marketChart(now)
} }
return mp return mp
} }
func (s *State) marketChart(q *Quote, now int64) MarketChart { func marketChart(now int64) MarketChart {
// Pad the range so the line doesn't touch the edges. return MarketChart{Step: marketStep.Milliseconds(), Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
pad := max((q.High-q.Low)*0.12, q.Rate*0.005)
lo, hi := q.Low-pad, q.High+pad
c := MarketChart{Step: marketStep.Milliseconds(),
Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
c.Line, c.Area = chartPaths(q.series, 1, chartW, chartH, lo, hi)
y := func(p float64) float64 { return chartH - (p-lo)/(hi-lo)*chartH }
c.HighY, c.LowY = y(q.High), y(q.Low)
prices := make([]string, len(q.series))
for i, p := range q.series {
prices[i] = strconv.FormatFloat(p, 'f', 4, 64)
}
c.Prices = strings.Join(prices, ",")
for i := 1; i <= 3; i++ {
p := lo + (hi-lo)*float64(i)/4
c.Grid = append(c.Grid, GridLine{Y: y(p), Top: y(p) / chartH * 100, Label: formatCredits(p)})
}
for h := 0; h <= 24; h += 6 {
label := fmt.Sprintf("−%dh", 24-h)
if h == 24 {
label = "Now"
}
c.Times = append(c.Times, TimeLabel{X: float64(h) / 24 * 100, Label: label})
}
return c
}
// chartPaths returns an SVG line path through every nth point of series, scaled to
// w×h between lo and hi, and the area under it.
func chartPaths(series []float64, every int, w, h, lo, hi float64) (line, area string) {
if len(series) < 2 {
return "", ""
}
if hi <= lo {
lo, hi = lo-1, hi+1
}
var b strings.Builder
last := len(series) - 1
for i := 0; i <= last; i += every {
if i+every > last {
i = last
}
x := float64(i) / float64(last) * w
y := h - (series[i]-lo)/(hi-lo)*h
cmd := "L"
if i == 0 {
cmd = "M"
}
fmt.Fprintf(&b, "%s%.1f %.1f", cmd, x, y)
}
line = b.String()
return line, fmt.Sprintf("%sL%.1f %.1fL0 %.1fZ", line, w, h, h)
} }
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"testing"
"time"
)
// TestMarketCache: the cached series match the uncached rates, and a new config snapshot
// (the economy editor's override) is picked up at once.
func TestMarketCache(t *testing.T) {
old := cfg()
defer effective.Store(old)
now := marketTick(time.Now())
item := "Sollux"
m := marketSeriesAt(item, now)
for _, tick := range []int64{now, now - 1, now - marketPoints + 1} {
if got, want := marketRateAt(item, tick), rateAt(old, item, tick); got != want {
t.Errorf("tick %d: cached %v, computed %v", tick, got, want)
}
}
if m.rate != rateAt(old, item, now) || m.open != rateAt(old, item, now-marketPoints+1) {
t.Errorf("series ends %v %v", m.open, m.rate)
}
c := baseConfig()
c.ResourcePrices[item] *= 2
effective.Store(&c)
if got, want := marketSeriesAt(item, now).rate, rateAt(&c, item, now); got != want || got == m.rate {
t.Errorf("after a config change: %v, want %v (was %v)", got, want, m.rate)
}
if got := marketRateAt(item, now-5); got != rateAt(&c, item, now-5) {
t.Errorf("after a config change, rate %v", got)
}
}
+11
View File
@@ -605,3 +605,14 @@ func runMoonTraits() error {
enc.SetIndent("", " ") enc.SetIndent("", " ")
return enc.Encode(out) return enc.Encode(out)
} }
// loadMoonDescription loads the description of one of the player's moons into s.Moons
// (loadState doesn't read them: only the moon view shows one).
func loadMoonDescription(ctx context.Context, q querier, s *State, moonID string) error {
m := s.moon(moonID)
if m == nil || m.Description != "" {
return nil
}
return q.QueryRow(ctx, "select description from staking_source where id = $1 and user_id = $2",
m.ID, s.UserID).Scan(&m.Description)
}
+8 -1
View File
@@ -68,10 +68,17 @@ func serve(ctx context.Context) error {
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, s.static, "favicon.ico") http.ServeFileFS(w, r, s.static, "favicon.ico")
}) })
// A Defold web build will be served here later; for now the admin. // 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) { mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/utils", http.StatusSeeOther) http.Redirect(w, r, "/utils", http.StatusSeeOther)
}) })
}
mux.HandleFunc("GET /utils/login", s.handleLoginPage) mux.HandleFunc("GET /utils/login", s.handleLoginPage)
mux.HandleFunc("POST /utils/login", s.handleLogin) mux.HandleFunc("POST /utils/login", s.handleLogin)
mux.HandleFunc("POST /utils/logout", s.handleLogout) mux.HandleFunc("POST /utils/logout", s.handleLogout)
@@ -0,0 +1,8 @@
-- Indexes for the per-request state reads (loadState, loadPastPage) and the stake and
-- claim checks: without them each request scanned these tables for every player.
create index if not exists staking_source_user on staking_source (user_id);
create index if not exists resource_well_source on resource_well (source_id);
create index if not exists inventory_item_user on inventory_item (user_id);
create index if not exists staking_event_user on staking_event (user_id, created_at);
create index if not exists staking_event_well on staking_event (well_id);
create index if not exists staking_event_tool on staking_event (inventory_item_id, created_at);
+6
View File
@@ -41,6 +41,7 @@ create table staking_source (
constraint fk_user_staking_source foreign key(user_id) constraint fk_user_staking_source foreign key(user_id)
references users(id) on delete cascade references users(id) on delete cascade
); );
create index staking_source_user on staking_source (user_id);
create table resource_well ( create table resource_well (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
@@ -52,6 +53,7 @@ create table resource_well (
constraint fk_rid_resource_well foreign key(resource_id) constraint fk_rid_resource_well foreign key(resource_id)
references resource(id) references resource(id)
); );
create index resource_well_source on resource_well (source_id);
create table inventory_item ( create table inventory_item (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
@@ -68,6 +70,7 @@ create table inventory_item (
constraint fk_store_item_inventory_item foreign key(store_item_id) constraint fk_store_item_inventory_item foreign key(store_item_id)
references store_item(id) on delete cascade references store_item(id) on delete cascade
); );
create index inventory_item_user on inventory_item (user_id);
create table upgrade_event ( create table upgrade_event (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
@@ -95,6 +98,9 @@ create table staking_event (
constraint fk_iiid_staking_event foreign key(inventory_item_id) constraint fk_iiid_staking_event foreign key(inventory_item_id)
references inventory_item(id) on delete cascade references inventory_item(id) on delete cascade
); );
create index staking_event_user on staking_event (user_id, created_at);
create index staking_event_well on staking_event (well_id);
create index staking_event_tool on staking_event (inventory_item_id, created_at);
create table claim_event ( create table claim_event (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
+5187
View File
File diff suppressed because it is too large. Load diff
+22 -10
View File
@@ -257,7 +257,6 @@ type TrackRow struct {
RateFrom float64 // credits per hour (creditsPerHour) now and after the next level RateFrom float64 // credits per hour (creditsPerHour) now and after the next level
RateTo float64 RateTo float64
Cost float64 // the next level's price plus its parts at sell value Cost float64 // the next level's price plus its parts at sell value
Ladder []TrackStep // every level, the other track as it is
} }
// TrackStep is one level of a track, for the drill panel's ladder. // TrackStep is one level of a track, for the drill panel's ladder.
@@ -286,7 +285,7 @@ func (s *State) trackValue(item *StoreItem, lv Levels, kind string) (value, rate
return yield, rate return yield, rate
} }
// trackRows are the tool's two tracks with their next levels and ladders. // trackRows are the tool's two tracks with their next levels (ladders: trackLadder).
func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow { func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
var rows []TrackRow var rows []TrackRow
lv := t.Levels() lv := t.Levels()
@@ -297,14 +296,6 @@ func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
} }
r := TrackRow{Kind: kind, Name: tr.Name, Level: lv.Of(kind), Max: len(tr.Levels)} r := TrackRow{Kind: kind, Name: tr.Name, Level: lv.Of(kind), Max: len(tr.Levels)}
r.Value, r.RateFrom = s.trackValue(item, lv, kind) r.Value, r.RateFrom = s.trackValue(item, lv, kind)
for n := 0; n <= r.Max; n++ {
st := TrackStep{Level: n, Current: n == r.Level, Next: n == r.Level+1}
st.Value, st.Rate = s.trackValue(item, lv.With(kind, n), kind)
if l := item.TrackLevel(kind, n); l != nil {
st.Price, st.Parts = l.Price, s.partRows(l.Parts)
}
r.Ladder = append(r.Ladder, st)
}
if next := item.TrackLevel(kind, r.Level+1); next != nil { if next := item.TrackLevel(kind, r.Level+1); next != nil {
r.Next = next r.Next = next
r.NextValue, r.RateTo = s.trackValue(item, lv.With(kind, r.Level+1), kind) r.NextValue, r.RateTo = s.trackValue(item, lv.With(kind, r.Level+1), kind)
@@ -328,6 +319,27 @@ func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
return rows return rows
} }
// trackLadder is every level of the item's track (by kind) at the levels lv, the other
// track as it is: the same for every tool of that item at those levels (the API sends it
// once per item and levels, api_sections.go ladderKey).
func (s *State) trackLadder(item *StoreItem, lv Levels, kind string) []TrackStep {
var out []TrackStep
tr := item.Track(kind)
if tr == nil {
return out
}
level := lv.Of(kind)
for n := 0; n <= len(tr.Levels); n++ {
st := TrackStep{Level: n, Current: n == level, Next: n == level+1}
st.Value, st.Rate = s.trackValue(item, lv.With(kind, n), kind)
if l := item.TrackLevel(kind, n); l != nil {
st.Price, st.Parts = l.Price, s.partRows(l.Parts)
}
out = append(out, st)
}
return out
}
// upgradedText names what a tool's latest upgrade on the track gave, for the toast. // upgradedText names what a tool's latest upgrade on the track gave, for the toast.
func (s *State) upgradedText(t Tool, kind string) string { func (s *State) upgradedText(t Tool, kind string) string {
item := s.StoreItem(t.StoreItemID) item := s.StoreItem(t.StoreItemID)
+2 -9
View File
@@ -626,8 +626,7 @@ func (s *State) page(v View) *Page {
for _, m := range s.Moons { for _, m := range s.Moons {
row := MoonListRow{Moon: m, Selected: m.ID == v.Moon, Over: s.wellsOver(&m)} row := MoonListRow{Moon: m, Selected: m.ID == v.Moon, Over: s.wellsOver(&m)}
var pending []time.Time var pending []time.Time
for _, st := range s.Stakes { for _, st := range s.stakesOn(m.ID) {
if st.MoonID == m.ID {
row.Drills++ row.Drills++
if st.Remaining(s.Now) == 0 { if st.Remaining(s.Now) == 0 {
row.Ready++ row.Ready++
@@ -638,7 +637,6 @@ func (s *State) page(v View) *Page {
} }
} }
} }
}
allPending = append(allPending, pending...) allPending = append(allPending, pending...)
row.Pending = endsAttr(pending) row.Pending = endsAttr(pending)
p.Ready += row.Ready p.Ready += row.Ready
@@ -998,12 +996,7 @@ func (s *State) project(m *Moon) projection {
n := len(m.Wells) n := len(m.Wells)
p := projection{Take: map[string][]float64{}, Gets: map[string]float64{}, Demand: make([]float64, n), p := projection{Take: map[string][]float64{}, Gets: map[string]float64{}, Demand: make([]float64, n),
Remain: m.supply(), Claims: make([]int, n), DryAfter: make([]int, n), DryAt: make([]time.Time, n)} Remain: m.supply(), Claims: make([]int, n), DryAfter: make([]int, n), DryAt: make([]time.Time, n)}
var stakes []Stake stakes := slices.Clone(s.stakesOn(m.ID))
for _, st := range s.Stakes {
if st.MoonID == m.ID {
stakes = append(stakes, st)
}
}
sort.SliceStable(stakes, func(i, j int) bool { return stakes[i].Start.Before(stakes[j].Start) }) sort.SliceStable(stakes, func(i, j int) bool { return stakes[i].Start.Before(stakes[j].Start) })
current := m.supply() current := m.supply()
last := make([]time.Time, n) // latest end among the drills that drew from each well so far last := make([]time.Time, n) // latest end among the drills that drew from each well so far
+6 -200
View File
@@ -138,8 +138,8 @@
--glow-k: 1; --glow-k: 1;
--tglow-k: 1; --tglow-k: 1;
--swatch: none; /* filter on resource and item color swatches */ --swatch: none; /* filter on resource and item color swatches */
--sans: "Rajdhani", system-ui, sans-serif; --sans: system-ui, sans-serif;
--mono: "Oxanium", ui-monospace, monospace; --mono: ui-monospace, monospace;
} }
/* Light: pale cool-grey paper, deep navy ink, the same hues darkened to read on it. Glows /* Light: pale cool-grey paper, deep navy ink, the same hues darkened to read on it. Glows
@@ -216,7 +216,7 @@
html, html,
body { body {
min-height: 100%; min-height: 100%;
background: radial-gradient(ellipse at 70% -10%, var(--bg-glow), var(--bg) 55%) fixed var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: var(--sans); font-family: var(--sans);
font-size: 15px; font-size: 15px;
@@ -346,34 +346,11 @@ h3 {
text-align: center; text-align: center;
} }
/* Panel with corner brackets. */ /* Panel. */
.frame { .frame {
position: relative; position: relative;
border: 1px solid var(--edge); border: 1px solid var(--edge);
background: linear-gradient(180deg, rgb(var(--hull-rgb) / 0.55), rgb(var(--deep-rgb) / 0.55)); background: rgb(var(--hull-rgb) / 0.4);
}
.frame::before,
.frame::after {
content: "";
position: absolute;
width: 14px;
height: 14px;
border-color: var(--cyan);
border-style: solid;
pointer-events: none;
}
.frame::before {
top: -1px;
left: -1px;
border-width: 2px 0 0 2px;
}
.frame::after {
right: -1px;
bottom: -1px;
border-width: 0 2px 2px 0;
} }
/* ---------------------------------------------------------------- controls */ /* ---------------------------------------------------------------- controls */
@@ -622,27 +599,13 @@ h3 {
margin: auto; margin: auto;
padding: 24px; padding: 24px;
border: 1px solid rgb(var(--cyan-rgb) / 0.4); border: 1px solid rgb(var(--cyan-rgb) / 0.4);
background: linear-gradient(180deg, var(--dialog-top), var(--dialog-bot)); background: var(--dialog-top);
color: var(--text); color: var(--text);
box-shadow: 0 0 0 1px rgb(var(--cyan-rgb) / calc(0.08 * var(--glow-k))), 0 20px 60px rgb(var(--shade-rgb) / calc(0.6 * var(--shade-k))), 0 0 40px rgb(var(--cyan-rgb) / calc(0.12 * var(--glow-k)));
overflow: hidden; overflow: hidden;
} }
.dialog::backdrop { .dialog::backdrop {
background: rgb(var(--scrim-rgb) / 0.7); background: rgb(var(--scrim-rgb) / 0.7);
backdrop-filter: blur(3px);
}
/* A scan line sweeps down the panel as it opens. */
.dialog form::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 2px;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
box-shadow: 0 0 12px var(--cyan);
opacity: 0;
pointer-events: none;
} }
.dialog-title { .dialog-title {
@@ -673,105 +636,6 @@ h3 {
color: var(--on-danger); color: var(--on-danger);
} }
.dialog[open] {
animation: dialog-in 0.28s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
.dialog[open]::backdrop {
animation: fade-in 0.28s ease-out;
}
.dialog[open] form::before {
animation: scan 0.7s ease-out 0.05s;
}
.dialog[open] .dialog-title,
.dialog[open] .dialog-message,
.dialog[open] .dialog-actions {
animation: rise-in 0.35s ease-out backwards;
}
.dialog[open] .dialog-message {
animation-delay: 0.06s;
}
.dialog[open] .dialog-actions {
animation-delay: 0.12s;
}
.dialog.is-closing {
animation: dialog-out 0.18s ease-in forwards;
}
.dialog.is-closing::backdrop {
animation: fade-out 0.18s ease-in forwards;
}
@keyframes dialog-in {
from {
opacity: 0;
transform: scale(0.94) translateY(8px);
clip-path: inset(48% 0 48% 0);
}
to {
opacity: 1;
transform: none;
clip-path: inset(0 0 0 0);
}
}
@keyframes dialog-out {
to {
opacity: 0;
transform: scale(0.97);
clip-path: inset(48% 0 48% 0);
}
}
@keyframes scan {
0% {
top: 0;
opacity: 1;
}
100% {
top: 100%;
opacity: 0;
}
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(6px);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dialog[open],
.dialog[open]::backdrop,
.dialog[open] form::before,
.dialog[open] .dialog-title,
.dialog[open] .dialog-message,
.dialog[open] .dialog-actions,
.dialog.is-closing,
.dialog.is-closing::backdrop {
animation: none;
}
}
.lo { .lo {
display: grid; display: grid;
gap: 20px; gap: 20px;
@@ -1005,7 +869,6 @@ h3 {
padding: 10px 18px; padding: 10px 18px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: rgb(var(--bg-rgb) / 0.92); background: rgb(var(--bg-rgb) / 0.92);
backdrop-filter: blur(6px);
} }
.eco-bar .input { .eco-bar .input {
@@ -1622,63 +1485,6 @@ body > .theme-toggle {
} }
/* ================================================================ tab ink
Sliding highlight + underline for tab strips (web/static/js/tabs.js). A strip is any
element with data-tabs; tabs.js adds one .tab-ink span to it and the .has-ink class while
the ink is drawn. Only then is the static underline (the aria-current border) hidden, so
without JS the strips look as before. */
[data-tabs].has-ink {
position: relative;
isolation: isolate;
}
[data-tabs].has-ink > a[aria-current] {
border-bottom-color: transparent;
}
.tab-ink {
display: none;
}
.has-ink > .tab-ink {
display: block;
position: absolute;
top: 0;
left: 0;
z-index: -1;
background: linear-gradient(180deg, transparent, rgb(var(--cyan-rgb) / 0.1));
pointer-events: none;
/* Slight overshoot at the end of the slide. */
transition:
transform 220ms cubic-bezier(0.3, 1.25, 0.5, 1),
width 220ms cubic-bezier(0.3, 1.25, 0.5, 1),
height 220ms ease-out;
}
/* The underline, with the HUD glow. */
.has-ink > .tab-ink::after {
content: "";
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 2px;
background: var(--cyan);
box-shadow: 0 0 8px rgb(var(--cyan-rgb) / calc(0.7 * var(--glow-k)));
}
.tab-ink.is-jump {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.has-ink > .tab-ink {
transition: none;
}
}
/* Charts (chart.go): server-drawn SVG; series colors by class (saved, draft). */ /* Charts (chart.go): server-drawn SVG; series colors by class (saved, draft). */
.ch-fig { .ch-fig {
margin: 8px 0 18px; margin: 8px 0 18px;
-112
View File
@@ -1,112 +0,0 @@
// Entrance animation for newly entered views: each .frame box "grows" (its corner brackets
// start together near the center and spread to the corners while the box unclips), then
// its content fades and slides in. Cards are staggered a little, capped so big grids stay
// quick (about 350ms in all).
//
// Only views that are entered animate, not every re-render: on the game page we compare the
// view (#view data-s/moon/well) before and after each htmx swap. A section or moon change
// animates #main; a well change animates the side panel. Admin pages animate on load
// (their nav is plain links), and a client-side tab strip with data-tabs-enter="<selector>"
// animates the matching elements when its active tab changes (the economy editor).
//
// Uses the Web Animations API, not classes: htmx "settles" the class and style attributes
// of swapped elements a moment after the swap, which would cut a class-based animation.
// With prefers-reduced-motion the content only fades.
const reduce = matchMedia("(prefers-reduced-motion: reduce)");
const EASE = "cubic-bezier(0.2, 0.8, 0.2, 1)";
const GROW = 180;
const FADE = 140;
const NESTED = 60; // frames inside a frame start a little after it
const STAGGER = 30;
const MAX_STAGGER = 60;
function fadeIn(el, delay) {
el.animate(
reduce.matches
? [{ opacity: 0 }, { opacity: 1 }]
: [
{ opacity: 0, translate: "0 6px" },
{ opacity: 1, translate: "0 0" },
],
{ duration: reduce.matches ? 120 : FADE, delay, easing: EASE, fill: "backwards" }
);
}
// One frame: brackets spread from the center, the box unclips, the content follows.
// The side panel isn't clipped (it holds the 3D viewport). Children that hold frames of
// their own don't fade: those frames animate themselves.
function grow(frame, delay, { clip = !frame.matches(".aside"), content = frame.children } = {}) {
content = [...content].filter((child) => !child.matches(".frame") && !child.querySelector(".frame"));
if (reduce.matches) {
for (const child of content) fadeIn(child, 0);
return;
}
const x = Math.max(0, frame.offsetWidth / 2 - 16);
const y = Math.max(0, frame.offsetHeight / 2 - 16);
const timing = { duration: GROW, delay, easing: EASE, fill: "backwards" };
frame.animate([{ translate: `${x}px ${y}px` }, { translate: "0 0" }], { ...timing, pseudoElement: "::before" });
frame.animate([{ translate: `${-x}px ${-y}px` }, { translate: "0 0" }], { ...timing, pseudoElement: "::after" });
if (clip) frame.animate([{ clipPath: `inset(${y}px ${x}px)` }, { clipPath: "inset(-2px)" }], timing);
for (const child of content) fadeIn(child, delay + GROW / 2);
}
// Animates the given regions and the frames inside them, in document order.
export function enter(regions) {
for (const region of regions) {
if (!region || region.hidden || region.offsetParent === null) continue;
let i = 0;
const outer = region.matches(".frame");
const frames = [...region.querySelectorAll(".frame:not(dialog)")].filter((f) => f.offsetParent !== null);
if (outer) grow(region, 0);
else if (frames.length === 0) fadeIn(region, 0);
for (const frame of frames) grow(frame, (outer ? NESTED : 0) + Math.min(i++ * STAGGER, MAX_STAGGER));
}
}
// ---------------------------------------------------------------- game page (htmx)
const viewEl = () => document.getElementById("view");
const read = () => {
const d = viewEl()?.dataset;
return d ? { main: `${d.s}|${d.moon}`, well: `${d.moon}|${d.well}` } : null;
};
let before = read();
if (before) {
// htmx:afterSwap fires once all the out-of-band regions are in, before the next paint.
document.body.addEventListener("htmx:afterSwap", () => {
const now = read();
if (!now || !before) return;
const aside = document.getElementById("aside");
if (now.main !== before.main) enter([document.getElementById("main")]);
if (now.well !== before.well && aside && !aside.hidden) {
if (reduce.matches) fadeIn(document.getElementById("side"), 0);
else grow(aside, 0, { clip: false, content: [document.getElementById("side")] });
}
before = now;
});
}
// ---------------------------------------------------------------- client-side tab strips
new MutationObserver((records) => {
for (const r of records) {
const group = r.target.parentElement?.closest("[data-tabs-enter]");
if (!group || !r.target.hasAttribute("aria-current") || r.oldValue !== null) continue;
// After the page's own handler has shown the new panel.
requestAnimationFrame(() => enter(document.querySelectorAll(group.dataset.tabsEnter)));
return;
}
}).observe(document.documentElement, {
subtree: true,
attributes: true,
attributeFilter: ["aria-current"],
attributeOldValue: true,
});
// ---------------------------------------------------------------- page load
// The whole page enters on load: the game's regions or an admin page's content.
enter(document.querySelectorAll(before ? "#main, #aside" : "body > *"));
-129
View File
@@ -1,129 +0,0 @@
// Sliding tab ink: one highlight + underline per tab strip that slides from the old active
// tab to the new one.
//
// A tab strip is any element with data-tabs="<name>"; its active tab is the child link with
// aria-current. The ink is a .tab-ink span added to the strip, which stays when htmx morphs
// the strip (main.js), so a new active tab just moves it and CSS transitions the slide.
// Client-only strips (the economy editor) work the same way. Full page loads (the admin
// nav) carry the old spot over in sessionStorage.
//
// Progressive enhancement: the group gets .has-ink only while the ink is drawn, and the CSS
// hides the static underline only then (web/css/app.css, "tab ink").
const KEY = "tabs-ink";
const CARRY_MS = 2000;
// The spot carried over from the previous page (an admin nav click), if recent.
let carried = null;
try {
const saved = JSON.parse(sessionStorage.getItem(KEY) || "null");
sessionStorage.removeItem(KEY);
if (saved && Date.now() - saved.t < CARRY_MS) carried = saved;
} catch {
// Storage unavailable: no slide across page loads.
}
const rectOf = (tab) => ({ x: tab.offsetLeft, y: tab.offsetTop, w: tab.offsetWidth, h: tab.offsetHeight });
const same = (a, b) => a && b && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
function draw(ink, r) {
ink.mmAt = r;
ink.style.transform = `translate(${r.x}px, ${r.y}px)`;
ink.style.width = `${r.w}px`;
ink.style.height = `${r.h}px`;
}
// Moves the ink without a transition.
function jump(ink, r) {
ink.classList.add("is-jump");
draw(ink, r);
void ink.offsetWidth; // apply before the transition comes back
ink.classList.remove("is-jump");
}
function sync({ animate = true } = {}) {
for (const group of document.querySelectorAll("[data-tabs]")) {
const tab = group.querySelector(":scope > [aria-current]");
let ink = group.querySelector(":scope > .tab-ink");
if (!tab || tab.offsetParent === null) {
// Nothing active (or hidden): the static look, and a fresh start when it comes back.
group.classList.remove("has-ink");
ink?.remove();
continue;
}
group.classList.add("has-ink");
const to = rectOf(tab);
if (!ink) {
ink = document.createElement("span");
ink.className = "tab-ink";
ink.setAttribute("aria-hidden", "true");
group.prepend(ink);
observed.add(group);
resized.observe(group);
// A new strip starts on its tab, or where the previous page's ink was.
const from = carried?.name === group.dataset.tabs ? carried : null;
if (from) carried = null;
jump(ink, from ?? to);
}
if (same(ink.mmAt, to)) continue; // unchanged (also the resize callback mid-slide)
if (animate) draw(ink, to);
else jump(ink, to);
}
// Strips that left the page are let go.
for (const el of observed) {
if (el.isConnected) continue;
resized.unobserve(el);
observed.delete(el);
}
}
// One sync per frame, however many swaps or attribute changes happened.
let queued = false;
let queuedAnimate = true;
function schedule(animate = true) {
queuedAnimate &&= animate;
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
const a = queuedAnimate;
queuedAnimate = true;
sync({ animate: a });
});
}
// Resizes (window, fonts loading, badges) reposition the ink without sliding.
const resized = new ResizeObserver(() => schedule(false));
const observed = new Set(); // the strips resized watches
document.fonts?.ready.then(() => schedule(false));
// htmx swaps (clicks, popstate, timers) and client-side tab changes. Other DOM churn (market
// ticks, toasts) is skipped so it doesn't cost a layout read.
const touchesTabs = (node) =>
node.nodeType === 1 && (node.matches("[data-tabs]") || node.querySelector("[data-tabs]") || node.closest("[data-tabs]"));
const relevant = (r) =>
r.type === "attributes" || touchesTabs(r.target) || [...r.addedNodes, ...r.removedNodes].some(touchesTabs);
new MutationObserver((records) => {
if (records.some(relevant)) schedule();
}).observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["aria-current"],
});
// A plain link in a strip loads a new page: carry the ink's spot over.
document.addEventListener("click", (event) => {
const link = event.target.closest?.("[data-tabs] > a[href]");
if (!link || link.hasAttribute("hx-get") || event.defaultPrevented) return;
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const from = link.parentElement.querySelector(":scope > .tab-ink")?.mmAt;
if (!from) return;
try {
sessionStorage.setItem(KEY, JSON.stringify({ ...from, name: link.parentElement.dataset.tabs, t: Date.now() }));
} catch {
// Storage unavailable.
}
});
sync();
+3 -16
View File
@@ -1,10 +1,8 @@
// Dialogs and toasts shared by the game page (main.js) and the admin pages (liveops.js, // Dialogs and toasts shared by the game page (main.js) and the admin pages (liveops.js,
// economy.js, moons-admin.js). // economy.js, moons-admin.js).
// //
// Dialogs (.dialog, a <dialog> holding a <form method="dialog">) play a closing animation // Dialogs (.dialog, a <dialog> holding a <form method="dialog">) close on Escape, a backdrop
// (CSS: .dialog.is-closing) and close on Escape, a backdrop click or a form button. // click or a form button.
const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
// onClose(value) runs when the player closes the dialog (the game page plays a sound). // onClose(value) runs when the player closes the dialog (the game page plays a sound).
export function wireDialog(dialog, { onClose } = {}) { export function wireDialog(dialog, { onClose } = {}) {
@@ -28,20 +26,9 @@ export function wireDialog(dialog, { onClose } = {}) {
// Plays the closing animation, then closes the dialog with `value` as its returnValue. // Plays the closing animation, then closes the dialog with `value` as its returnValue.
export function closeDialog(dialog, value, { quiet = false } = {}) { export function closeDialog(dialog, value, { quiet = false } = {}) {
if (!dialog.open || dialog.classList.contains("is-closing")) return; if (!dialog.open) return;
if (!quiet) dialog.mmOnClose?.(value); if (!quiet) dialog.mmOnClose?.(value);
const done = () => {
dialog.classList.remove("is-closing");
dialog.close(value); dialog.close(value);
};
if (reduced()) return done();
dialog.classList.add("is-closing");
const onEnd = (event) => {
if (event.target !== dialog || event.animationName !== "dialog-out") return;
dialog.removeEventListener("animationend", onEnd);
done();
};
dialog.addEventListener("animationend", onEnd);
} }
// Asks a question in the confirm dialog (#confirm); resolves true for OK. // Asks a question in the confirm dialog (#confirm); resolves true for OK.
-2
View File
@@ -29,8 +29,6 @@
}); });
})(); })();
</script> </script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Oxanium:wght@400;500;600&family=Rajdhani:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="/static/css/admin.css"> <link rel="stylesheet" href="/static/css/admin.css">
{{end}} {{end}}
+1 -3
View File
@@ -3,8 +3,6 @@
rendered here (economy.go, sim.go) from the draft. */}} rendered here (economy.go, sim.go) from the draft. */}}
{{define "economy"}}{{template "head"}} {{define "economy"}}{{template "head"}}
<script type="module" src="/static/js/tabs.js"></script>
<script type="module" src="/static/js/enter.js"></script>
<script type="module" src="/static/js/economy.js"></script> <script type="module" src="/static/js/economy.js"></script>
</head> </head>
<body> <body>
@@ -26,7 +24,7 @@
<div id="eco-status" class="eco-status" aria-live="polite">{{if .Message}}<span class="eco-ok">{{.Message}}</span>{{end}}</div> <div id="eco-status" class="eco-status" aria-live="polite">{{if .Message}}<span class="eco-ok">{{.Message}}</span>{{end}}</div>
</div> </div>
<nav class="eco-tabs" aria-label="Tables" data-tabs="eco" data-tabs-enter=".eco-pane:not([hidden])"> <nav class="eco-tabs" aria-label="Tables" data-tabs="eco">
<a href="#equipment" data-tab="equipment">Equipment</a> <a href="#equipment" data-tab="equipment">Equipment</a>
<a href="#resources" data-tab="resources">Resources</a> <a href="#resources" data-tab="resources">Resources</a>
<a href="#refined" data-tab="refined">Refined</a> <a href="#refined" data-tab="refined">Refined</a>
-2
View File
@@ -3,8 +3,6 @@
{{define "liveops"}}{{template "head"}} {{define "liveops"}}{{template "head"}}
<script src="/static/vendor/htmx.min.js"></script> <script src="/static/vendor/htmx.min.js"></script>
<script type="module" src="/static/js/tabs.js"></script>
<script type="module" src="/static/js/enter.js"></script>
{{if .Allowed}}<script type="module" src="/static/js/liveops.js"></script>{{end}} {{if .Allowed}}<script type="module" src="/static/js/liveops.js"></script>{{end}}
</head> </head>
<body> <body>
-4
View File
@@ -11,8 +11,6 @@
{{define "players"}}{{template "head"}} {{define "players"}}{{template "head"}}
<script src="/static/vendor/htmx.min.js"></script> <script src="/static/vendor/htmx.min.js"></script>
<script type="module" src="/static/js/tabs.js"></script>
<script type="module" src="/static/js/enter.js"></script>
</head> </head>
<body> <body>
{{if .Allowed}} {{if .Allowed}}
@@ -83,8 +81,6 @@
{{define "player"}}{{template "head"}} {{define "player"}}{{template "head"}}
<script src="/static/vendor/htmx.min.js"></script> <script src="/static/vendor/htmx.min.js"></script>
<script type="module" src="/static/js/tabs.js"></script>
<script type="module" src="/static/js/enter.js"></script>
{{if .Allowed}}<script type="module" src="/static/js/liveops.js"></script>{{end}} {{if .Allowed}}<script type="module" src="/static/js/liveops.js"></script>{{end}}
</head> </head>
<body> <body>