Compare commits
25
Commits
4bb2188564
..
main
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
ui.rect(c, x, y, inner, 1, "line_hi")
|
return
|
||||||
y = y + 1
|
|
||||||
end
|
end
|
||||||
local seen, rh = {}, row_h(open, geo.wide)
|
ui.rect(c, x, y, inner, 1, "line_hi")
|
||||||
|
y = y + 1
|
||||||
|
-- Each copy's number among tools of its type (in the table's order).
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+422
-255
@@ -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,
|
if #groups == 0 then
|
||||||
edge = not ok and "line" or nil, fill = not ok and { "cyan", 0 } or nil })
|
v.title, v.sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
|
||||||
local title, sub, sub_color
|
elseif not grp then
|
||||||
if #groups == 0 then
|
v.title, v.sub = "No room here", "The drills on it take all it has"
|
||||||
title, sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
|
elseif not ok then
|
||||||
elseif not grp then
|
v.title, v.sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
|
||||||
title, sub = "No room here", "The drills on it take all it has"
|
else
|
||||||
elseif not ok then
|
local t = grp.tool
|
||||||
title, sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
|
local reach = reach_short(st, t.reach)
|
||||||
else
|
v.title = "+ Deploy " .. M.short_name(t)
|
||||||
local t = grp.tool
|
v.sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
|
||||||
local reach = reach_short(st, t.reach)
|
if here < amount - EPS then
|
||||||
title = "+ Deploy " .. M.short_name(t)
|
v.sub, v.color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
|
||||||
sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
|
end
|
||||||
if here < amount - EPS then
|
end
|
||||||
sub, sub_color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a
|
||||||
|
-- cycle gets here, or why it can't (no idle drills, no room). v: deploy_view. It sends
|
||||||
|
-- the group's first idle copy at the click (a kept slot outlives the drill it was built
|
||||||
|
-- with); its test id is the well's: act:stake?target=<well>.
|
||||||
|
local function deploy_slot(g, well, v, mk, H)
|
||||||
|
return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
|
||||||
|
part(g, "mo:deploy:" .. well.id, blocks.sig(w, v, mk), p0, x0, y0, function(p)
|
||||||
|
local x, y = 0, 0
|
||||||
|
-- (off: a plain line and no fill, so it reads as greyed beside a live slot)
|
||||||
|
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not v.ok,
|
||||||
|
edge = not v.ok and "line" or nil, fill = not v.ok and { "cyan", 0 } or nil })
|
||||||
|
local tree = col({ width = w, height = SLOT_H, padding = { 0, 10 }, justify = "center", align = "center", gap = 2 }, {
|
||||||
|
fit(b, v.title, { font = "sans_semi", px = 13, color = v.ok and "cyan" or "muted", tracking = 0.06, upper = true }),
|
||||||
|
fit(b, v.sub, { font = "sans", px = 12, color = v.color or "muted" }),
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(tree, 0, 0), b)
|
||||||
|
if v.ok then
|
||||||
|
local function params()
|
||||||
|
for _, grp in ipairs(M.idle_groups(g.state)) do
|
||||||
|
if grp.key == v.key then
|
||||||
|
return { tool = grp.tool.id, target = well.id }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return { tool = v.tool, target = well.id } -- (gone since: the server says why)
|
||||||
|
end
|
||||||
|
H.action(g, b, bn, "stake", params, nil, nil, "act:stake?target=" .. well.id)
|
||||||
|
tooltip.add(b, "Deploy " .. (v.name or "a drill") .. (v.explicit and "" or " (the smallest idle drill with room here)"))
|
||||||
end
|
end
|
||||||
end
|
end)
|
||||||
local tree = col({ width = w, height = SLOT_H, padding = { 0, 10 }, justify = "center", align = "center", gap = 2 }, {
|
|
||||||
fit(b, title, { font = "sans_semi", px = 13, color = ok and "cyan" or "muted", tracking = 0.06, upper = true }),
|
|
||||||
fit(b, sub, { font = "sans", px = 12, color = sub_color or "muted" }),
|
|
||||||
})
|
|
||||||
fg.build(flex.solve(tree, 0, 0), b)
|
|
||||||
if ok then
|
|
||||||
local params = { tool = grp.tool.id, target = well.id }
|
|
||||||
H.action(g, b, bn, "stake", params)
|
|
||||||
tooltip.add(b, "Deploy " .. (grp.tool.name or "a drill") .. (explicit and "" or " (the smallest idle drill with room here)"))
|
|
||||||
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,105 +399,132 @@ 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)
|
||||||
local slot = ui.rect(p, x, y, w, SLOT_H, "none")
|
part(g, "mo:slot:" .. d.toolId, blocks.sig(w, d, dm, scope, can_redeploy, n, wait, mk), p0, x0, y0, function(p)
|
||||||
local body = ui.rect(slot, 0, 0, w, SLOT_H, due and "ready" or "cyan", due and 0.08 or 0.04)
|
local x, y = 0, 0
|
||||||
ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil)
|
local slot = ui.rect(p, x, y, w, SLOT_H, "none")
|
||||||
local iw = w - 18
|
local body = ui.rect(slot, 0, 0, w, SLOT_H, due and "ready" or "cyan", due and 0.08 or 0.04)
|
||||||
local right
|
ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil)
|
||||||
local gets = d.here or d.take or d.amount or 0
|
local iw = w - 18
|
||||||
if due then
|
local right
|
||||||
-- (one that gets nothing is claimed all the same: it frees the drill)
|
local gets = d.here or d.take or d.amount or 0
|
||||||
right = fg.label(body, gets > 0 and ("+" .. ui.money(gets) .. " ready") or "Nothing left",
|
if due then
|
||||||
{ font = "sans_semi", px = 12, color = gets > 0 and "ready" or "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 })
|
-- (one that gets nothing is claimed all the same: it frees the drill)
|
||||||
else
|
right = fg.label(body, gets > 0 and ("+" .. ui.money(gets) .. " ready") or "Nothing left",
|
||||||
right = fg.label(body, ui.dur(d["end"] - H.now(g)), M12, 16, { shrink = 0 })
|
{ font = "sans_semi", px = 12, color = gets > 0 and "ready" or "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 })
|
||||||
right.done = function(t)
|
|
||||||
H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local second
|
|
||||||
if due then
|
|
||||||
local suffix = n > 1 and ("#" .. n) or ""
|
|
||||||
local kids = {
|
|
||||||
fg.button(body, "Claim", { primary = true, small = true, h = SLOT_BTN_H }, function(b, bn)
|
|
||||||
H.action(g, b, bn, "claim", scope, nil, nil, n > 1 and (act_id("claim", scope) .. suffix) or nil)
|
|
||||||
tooltip.add(b, "Claim every ready drill here")
|
|
||||||
end, { grow = 1, height = SLOT_BTN_H }),
|
|
||||||
}
|
|
||||||
if can_redeploy then
|
|
||||||
local rp = { redeploy = "1" }
|
|
||||||
for k, v in pairs(scope) do
|
|
||||||
rp[k] = v
|
|
||||||
end
|
|
||||||
kids[2] = space(6)
|
|
||||||
kids[3] = fg.button(body, "Redeploy", { small = true, h = SLOT_BTN_H }, function(b, bn)
|
|
||||||
H.action(g, b, bn, "claim", rp, nil, nil, n > 1 and (act_id("claim", rp) .. suffix) or nil)
|
|
||||||
tooltip.add(b, "Claim, then send the drills out again")
|
|
||||||
end, { height = SLOT_BTN_H })
|
|
||||||
end
|
|
||||||
second = row({ height = SLOT_BTN_H, align = "center" }, kids)
|
|
||||||
else
|
|
||||||
local color = d.state == "short" and "ready" or "cyan"
|
|
||||||
local gets
|
|
||||||
if d.state == "none" then
|
|
||||||
gets = fit(body, "Nothing left by its turn", { font = "sans", px = 11, color = "ready" })
|
|
||||||
else
|
else
|
||||||
local here = ui.money(d.here or d.take or 0)
|
right = fg.label(body, ui.dur(d["end"] - H.now(g)), M12, 16, { shrink = 0 })
|
||||||
gets = fit(body, "Gets " .. here .. (d.state == "short" and (" of " .. ui.money(d.amount)) or "")
|
right.done = function(t)
|
||||||
.. ((d.reach or 1) > 1 and (d.gets and d.gets ~= d.here) and (" · " .. ui.money(d.gets) .. " in all") or ""),
|
H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
|
||||||
{ font = "sans", px = 11, color = d.state == "short" and "ready" or "muted" })
|
|
||||||
end
|
|
||||||
second = col({ height = SLOT_BTN_H, gap = 4, padding_top = 3 }, {
|
|
||||||
made({ height = 5, shrink = 0 }, function(q, bx, by, bw)
|
|
||||||
bar.timer(g, q, bx, by, bw, 5, { start = d.start, ends = d["end"], ready = false, color = color,
|
|
||||||
none = d.state == "none", key = "slot:" .. d.toolId })
|
|
||||||
end),
|
|
||||||
gets,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
|
|
||||||
row({ height = 16, align = "center" }, {
|
|
||||||
fit(body, M.short_name(tool), NAME13),
|
|
||||||
reach and space(5) or nil, reach and fit(body, "· " .. reach, S12) or nil,
|
|
||||||
fg.fill(), space(6), right,
|
|
||||||
}),
|
|
||||||
second,
|
|
||||||
})
|
|
||||||
fg.build(flex.solve(tree, 0, 0), body)
|
|
||||||
if wait and not due then
|
|
||||||
local function spot(params, style)
|
|
||||||
return made(style, function(q, bx, by, bw, bh)
|
|
||||||
test.register("wait:" .. act_id("claim", params):sub(5), ui.rect(q, bx, by, bw, bh, "none"), "wait", nil, nil, true)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
local kids = { spot(scope, { grow = 1, height = SLOT_BTN_H }) }
|
|
||||||
if can_redeploy then
|
|
||||||
local rp = { redeploy = "1" }
|
|
||||||
for k, v in pairs(scope) do
|
|
||||||
rp[k] = v
|
|
||||||
end
|
end
|
||||||
kids[2] = space(6)
|
|
||||||
kids[3] = spot(rp, { width = fg.button_w(body, "Redeploy", { small = true, h = SLOT_BTN_H }), height = SLOT_BTN_H, shrink = 0 })
|
|
||||||
end
|
end
|
||||||
fg.build(flex.solve(row({ width = w - 18, height = SLOT_BTN_H }, kids), 9, 27), body)
|
local second
|
||||||
end
|
if due then
|
||||||
if d.fate and d.fate ~= "" then
|
local suffix = n > 1 and ("#" .. n) or ""
|
||||||
tooltip.add(ui.rect(body, 0, 0, iw, 23, "none"), d.fate)
|
local kids = {
|
||||||
end
|
fg.button(body, "Claim", { primary = true, small = true, h = SLOT_BTN_H }, function(b, bn)
|
||||||
enters[#enters + 1] = function()
|
H.action(g, b, bn, "claim", scope, nil, nil, n > 1 and (act_id("claim", scope) .. suffix) or nil)
|
||||||
row_c.enter(g, "drill:" .. d.toolId, body, w, SLOT_H, { base = gui.get_color(body) })
|
tooltip.add(b, "Claim every ready drill here")
|
||||||
end
|
end, { grow = 1, height = SLOT_BTN_H }),
|
||||||
|
}
|
||||||
|
if can_redeploy then
|
||||||
|
local rp = { redeploy = "1" }
|
||||||
|
for k, v in pairs(scope) do
|
||||||
|
rp[k] = v
|
||||||
|
end
|
||||||
|
kids[2] = space(6)
|
||||||
|
kids[3] = fg.button(body, "Redeploy", { small = true, h = SLOT_BTN_H }, function(b, bn)
|
||||||
|
H.action(g, b, bn, "claim", rp, nil, nil, n > 1 and (act_id("claim", rp) .. suffix) or nil)
|
||||||
|
tooltip.add(b, "Claim, then send the drills out again")
|
||||||
|
end, { height = SLOT_BTN_H })
|
||||||
|
end
|
||||||
|
second = row({ height = SLOT_BTN_H, align = "center" }, kids)
|
||||||
|
else
|
||||||
|
local color = d.state == "short" and "ready" or "cyan"
|
||||||
|
local gets
|
||||||
|
if d.state == "none" then
|
||||||
|
gets = fit(body, "Nothing left by its turn", { font = "sans", px = 11, color = "ready" })
|
||||||
|
else
|
||||||
|
local here = ui.money(d.here or d.take or 0)
|
||||||
|
gets = fit(body, "Gets " .. here .. (d.state == "short" and (" of " .. ui.money(d.amount)) or "")
|
||||||
|
.. ((d.reach or 1) > 1 and (d.gets and d.gets ~= d.here) and (" · " .. ui.money(d.gets) .. " in all") or ""),
|
||||||
|
{ font = "sans", px = 11, color = d.state == "short" and "ready" or "muted" })
|
||||||
|
end
|
||||||
|
second = col({ height = SLOT_BTN_H, gap = 4, padding_top = 3 }, {
|
||||||
|
made({ height = 5, shrink = 0 }, function(q, bx, by, bw)
|
||||||
|
bar.timer(g, q, bx, by, bw, 5, { start = d.start, ends = d["end"], ready = false, color = color,
|
||||||
|
none = d.state == "none", key = "slot:" .. d.toolId })
|
||||||
|
end),
|
||||||
|
gets,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
|
||||||
|
row({ height = 16, align = "center" }, {
|
||||||
|
fit(body, dm.name, NAME13),
|
||||||
|
reach and space(5) or nil, reach and fit(body, "· " .. reach, S12) or nil,
|
||||||
|
fg.fill(), space(6), right,
|
||||||
|
}),
|
||||||
|
second,
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(tree, 0, 0), body)
|
||||||
|
if wait and not due then
|
||||||
|
local function spot(params, style)
|
||||||
|
return made(style, function(q, bx, by, bw, bh)
|
||||||
|
test.register("wait:" .. act_id("claim", params):sub(5), ui.rect(q, bx, by, bw, bh, "none"), "wait", nil, nil, true)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
local kids = { spot(scope, { grow = 1, height = SLOT_BTN_H }) }
|
||||||
|
if can_redeploy then
|
||||||
|
local rp = { redeploy = "1" }
|
||||||
|
for k, v in pairs(scope) do
|
||||||
|
rp[k] = v
|
||||||
|
end
|
||||||
|
kids[2] = space(6)
|
||||||
|
kids[3] = spot(rp, { width = fg.button_w(body, "Redeploy", { small = true, h = SLOT_BTN_H }), height = SLOT_BTN_H, shrink = 0 })
|
||||||
|
end
|
||||||
|
fg.build(flex.solve(row({ width = w - 18, height = SLOT_BTN_H }, kids), 9, 27), body)
|
||||||
|
end
|
||||||
|
if d.fate and d.fate ~= "" then
|
||||||
|
tooltip.add(ui.rect(body, 0, 0, iw, 23, "none"), d.fate)
|
||||||
|
end
|
||||||
|
enters[#enters + 1] = function()
|
||||||
|
row_c.enter(g, "drill:" .. d.toolId, body, w, SLOT_H, { base = gui.get_color(body) })
|
||||||
|
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)
|
|
||||||
local tree = col({ width = w, height = TILE_H, padding = { 8, 8, 8, TILE_H }, gap = 4 }, {
|
|
||||||
row({ height = 16, align = "center" }, {
|
|
||||||
fit(b, M.short_name(t), NAME13), fg.fill(), space(4),
|
|
||||||
fg.label(b, "×" .. grp.count, { font = "mono", px = 12, color = picked and "cyan" or "text_2" }, 16, { shrink = 0 }),
|
|
||||||
}),
|
|
||||||
fit(b, "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or ""), { font = "mono", px = 11, color = "muted" }),
|
|
||||||
})
|
|
||||||
fg.build(flex.solve(tree, 0, 0), b)
|
|
||||||
if picked then
|
|
||||||
ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9)
|
|
||||||
end
|
|
||||||
if ok then
|
|
||||||
tooltip.add(b, "Deploy " .. (t.name or "a drill") .. " to the " .. well.resource .. " well")
|
|
||||||
H.action(g, b, bn, "stake", { tool = t.id, target = well.id })
|
|
||||||
else
|
|
||||||
tooltip.add(b, (t.name or "Drill") .. (well and (" · no room on the " .. well.resource .. " well") or " · no room on this moon"))
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
return col({ width = inner }, { head, space(0, 8), flex.grid(inner, TILE_MIN, 8, tiles) })
|
return { tiles = tiles, store = st.unlocked.store and true or false, moon = g.view.moon, marks = marks(g, keys) }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The tray (inner wide, at the origin of c): a tile per group (picture, short name,
|
||||||
|
-- ×count, what a cycle gets); a tile deploys one of them. m: tray_model. Returns its
|
||||||
|
-- height.
|
||||||
|
local function tray(g, c, m, inner, H)
|
||||||
|
local head = row({ height = 16, align = "center" }, {
|
||||||
|
fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0),
|
||||||
|
#m.tiles > 0 and fit(c, "Click one to deploy it to the selected well", "small") or nil,
|
||||||
|
})
|
||||||
|
local tree
|
||||||
|
if #m.tiles == 0 then
|
||||||
|
tree = col({ width = inner }, {
|
||||||
|
head, space(0, 8),
|
||||||
|
row({ height = 16, align = "center" }, {
|
||||||
|
fg.label(c, "No idle drills.", "small", 16),
|
||||||
|
m.store and space(5) or nil,
|
||||||
|
m.store and made({ width = 160, height = 16, shrink = 0 }, function(p, x, y)
|
||||||
|
H.text_link(g, p, "Buy one in the store", { font = "sans", px = 13 }, x, y + 8, { section = "store", moon = m.moon })
|
||||||
|
end) or nil,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
else
|
||||||
|
local tiles = {}
|
||||||
|
for _, tv in ipairs(m.tiles) do
|
||||||
|
local picked = false
|
||||||
|
tiles[#tiles + 1] = made({ height = TILE_H }, function(p0, x0, y0, w)
|
||||||
|
part(g, "mo:tile:" .. tv.key, blocks.sig(w, tv, m.marks), p0, x0, y0, function(p)
|
||||||
|
local x, y = 0, 0
|
||||||
|
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = TILE_H, disabled = not tv.ok })
|
||||||
|
local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg")
|
||||||
|
if tv.image then
|
||||||
|
images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), tv.image)
|
||||||
|
end
|
||||||
|
local tt = col({ width = w, height = TILE_H, padding = { 8, 8, 8, TILE_H }, gap = 4 }, {
|
||||||
|
row({ height = 16, align = "center" }, {
|
||||||
|
fit(b, tv.name, NAME13), fg.fill(), space(4),
|
||||||
|
fg.label(b, "×" .. tv.count, { font = "mono", px = 12, color = picked and "cyan" or "text_2" }, 16, { shrink = 0 }),
|
||||||
|
}),
|
||||||
|
fit(b, tv.gets, { font = "mono", px = 11, color = "muted" }),
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(tt, 0, 0), b)
|
||||||
|
if picked then
|
||||||
|
ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9)
|
||||||
|
end
|
||||||
|
if tv.ok then
|
||||||
|
tooltip.add(b, "Deploy " .. (tv.tname or "a drill") .. " to the " .. tv.resource .. " well")
|
||||||
|
H.action(g, b, bn, "stake", { tool = tv.tool, target = tv.well })
|
||||||
|
else
|
||||||
|
tooltip.add(b, (tv.tname or "Drill") .. (tv.well and (" · no room on the " .. tv.resource .. " well") or " · no room on this moon"))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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}}
|
||||||
|
|||||||
+41
-18
@@ -9,6 +9,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -303,8 +304,11 @@ type APILiveEvent struct {
|
|||||||
// ------------------------------------------------------------------ equipment
|
// ------------------------------------------------------------------ equipment
|
||||||
|
|
||||||
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
|
||||||
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
|
// 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>
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIToolRow struct {
|
type APIToolRow struct {
|
||||||
@@ -321,8 +325,9 @@ type APIToolRow struct {
|
|||||||
CycleSecs float64 `json:"cycleSecs"`
|
CycleSecs float64 `json:"cycleSecs"`
|
||||||
Yield float64 `json:"yield"`
|
Yield float64 `json:"yield"`
|
||||||
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"`
|
||||||
@@ -332,13 +337,12 @@ type APIToolRow struct {
|
|||||||
// APITrack is one of a tool's upgrade tracks: values are a stake's now (yield per cycle,
|
// APITrack is one of a tool's upgrade tracks: values are a stake's now (yield per cycle,
|
||||||
// or the cycle in seconds), with bonuses and live events.
|
// or the cycle in seconds), with bonuses and live events.
|
||||||
type APITrack struct {
|
type APITrack struct {
|
||||||
Kind string `json:"kind"` // yield, cycle: upgrade with tool=<id>&track=<kind>
|
Kind string `json:"kind"` // yield, cycle: upgrade with tool=<id>&track=<kind>
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+651
-51
@@ -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,13 +365,15 @@ 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
|
||||||
})
|
})
|
||||||
@@ -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,23 +1325,43 @@ 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 a claimAccount
|
||||||
|
var rid int
|
||||||
|
_, err = pgx.ForEachRow(rows, []any{&a.ID, &rid, &a.Balance}, func() error {
|
||||||
|
ca := a
|
||||||
|
c.accounts[rid] = append(c.accounts[rid], &ca)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
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
|
var moons []string // the moons claimed on, once each
|
||||||
for _, st := range stakes {
|
for _, st := range readyStakes {
|
||||||
amount, stopped, err := claimInTx(ctx, tx, userID, st.ID)
|
amount, stopped, ok := c.claim(st.ID)
|
||||||
if err == errAlreadyClaimed {
|
if !ok {
|
||||||
continue // claimed by a concurrent request
|
continue // claimed by a concurrent request, or stopped by an earlier claim
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
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,27 +1381,481 @@ 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 err != nil {
|
if !c.finished(m) {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !done {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m := Moon{ID: id}
|
res.Finished = append(res.Finished, Moon{ID: id, Name: m.Name})
|
||||||
if err := tx.QueryRow(ctx, "select name from staking_source where id = $1", id).Scan(&m.Name); err != nil {
|
c.log("moon_finished", map[string]any{"moon": id, "name": m.Name})
|
||||||
return err
|
|
||||||
}
|
|
||||||
res.Finished = append(res.Finished, m)
|
|
||||||
if err := logEvent(ctx, tx, userID, "moon_finished", map[string]any{"moon": id, "name": m.Name}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return c.write(ctx, tx)
|
||||||
})
|
})
|
||||||
return res, err
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var st claimStake
|
||||||
|
_, 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 {
|
||||||
|
s := st
|
||||||
|
c.stakes[s.ID] = &s
|
||||||
|
c.moons[s.MoonID].Stakes = append(c.moons[s.MoonID].Stakes, &s)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = tx.QueryRow(ctx, "select onboarding, now() from users where id = $1", c.userID).Scan(&c.flags, &c.now)
|
||||||
|
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 nil
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
// to fill what the well has left with the least to spare: while there is room, the
|
// to fill what the well has left with the least to spare: while there is room, the
|
||||||
// smallest drill that would finish the job alone, else the biggest one and again (two
|
// smallest drill that would finish the job alone, else the biggest one and again (two
|
||||||
@@ -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
@@ -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)
|
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+11
-4
@@ -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
|
||||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
// dist/web/MoonMiners beside this checkout); without one, / goes to the admin.
|
||||||
http.Redirect(w, r, "/utils", http.StatusSeeOther)
|
webDir := env("MM_WEB_DIR", "../client/dist/web/MoonMiners")
|
||||||
})
|
if st, err := os.Stat(webDir + "/index.html"); err == nil && !st.IsDir() {
|
||||||
|
// "/", not "GET /": that would conflict with the method-less /utils patterns (panic)
|
||||||
|
mux.Handle("/", http.FileServerFS(os.DirFS(webDir)))
|
||||||
|
} else {
|
||||||
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/utils", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
}
|
||||||
mux.HandleFunc("GET /utils/login", s.handleLoginPage)
|
mux.HandleFunc("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);
|
||||||
@@ -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(),
|
||||||
|
|||||||
Vendored
+5187
File diff suppressed because it is too large.
Load diff
+23
-11
@@ -256,8 +256,7 @@ type TrackRow struct {
|
|||||||
Short string // "Need ₥X more" when credits are short for it
|
Short string // "Need ₥X more" when credits are short for it
|
||||||
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)
|
||||||
|
|||||||
+9
-16
@@ -626,16 +626,14 @@ 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++
|
} else {
|
||||||
} else {
|
pending = append(pending, st.End())
|
||||||
pending = append(pending, st.End())
|
if p.NextEnd.IsZero() || st.End().Before(p.NextEnd) {
|
||||||
if p.NextEnd.IsZero() || st.End().Before(p.NextEnd) {
|
p.NextEnd = st.End()
|
||||||
p.NextEnd = st.End()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 > *"));
|
|
||||||
@@ -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();
|
|
||||||
@@ -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.close(value);
|
||||||
dialog.classList.remove("is-closing");
|
|
||||||
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.
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user