Compare commits
38
Commits
329b954e31
..
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>`
|
||||
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
|
||||
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
|
||||
- A worktree's `.env` names its own database (`DB_DATABASE=mm_<task>`), so `migrate`,
|
||||
tests and servers run there reach only `mm_<task>`. Never point anything at the main
|
||||
database (`moonminers`): it holds the author's game.
|
||||
- Never use `git stash` in a worktree: the stash list is shared by every worktree, so a
|
||||
pop can take another agent's work. Commit on your branch, or save a patch file.
|
||||
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
||||
commit it.
|
||||
|
||||
|
||||
@@ -14,25 +14,32 @@ Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
||||
podman-compose up -d db # or: docker compose up -d db
|
||||
cd server
|
||||
make migrate
|
||||
make dev # 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
|
||||
migrations and syncs the tools with `config/game.json` (the server alone doesn't).
|
||||
After pulling changes or editing `server/config/game.json`, run `make migrate` again before
|
||||
starting the server: it applies new migrations and syncs the tools and prices with the
|
||||
config (the server alone doesn't).
|
||||
|
||||
## Defold client
|
||||
|
||||
Needs Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor
|
||||
is optional: https://defold.com/download/
|
||||
|
||||
With the server running:
|
||||
Needs the Defold editor (https://defold.com/download/) or Java 21+. The Makefile downloads
|
||||
Defold's command-line builder (`bob.jar`) and runs it with the Java the editor ships, found
|
||||
in the usual places on Linux, macOS and Windows (`make DEFOLD=<folder holding its packages>`
|
||||
for another place, `JAVA=<path>` for your own Java); without an editor, `java` from PATH.
|
||||
|
||||
```bash
|
||||
cd client
|
||||
make run # desktop build, plays against http://localhost:3000
|
||||
make web && make serve-web # browser build: open http://localhost:8080
|
||||
make web # browser build into dist/web/MoonMiners/
|
||||
```
|
||||
|
||||
The server serves the browser build at http://localhost:3000 (from
|
||||
`client/dist/web/MoonMiners`, or `MM_WEB_DIR`). It looks for the build when it starts:
|
||||
after the first `make web`, restart the server; later builds only need a page reload.
|
||||
Without a build, `/` goes to the admin. The desktop and browser games keep separate saved
|
||||
players.
|
||||
|
||||
## Deploy
|
||||
|
||||
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
|
||||
+5
-1
@@ -42,7 +42,11 @@ map.
|
||||
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
|
||||
layout is top-left based (x right, y down).
|
||||
- Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua;
|
||||
`ASIDE_KEYS`, header and moon list sigs likewise) or it won't update. What must survive
|
||||
`ASIDE_KEYS` and the header's sig likewise; the moon list's rows: `moonlist.rows_sig`;
|
||||
the moon's facts in the aside: `aside.facts_sig`; the Moons parts: their models in
|
||||
moons.lua) or it won't update. The shown state is frozen (`blocks.freeze`: `blocks.sig`
|
||||
writes each part once per state, by content): never change `g.state` in place; a
|
||||
prediction changes its copy before it is shown. What must survive
|
||||
a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across
|
||||
builds outside its block. A new per-build registry notes its entries in the block journal
|
||||
(`blocks.note`/`push`/`put`); code that builds outside a build runs through
|
||||
|
||||
+28
-13
@@ -1,5 +1,17 @@
|
||||
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool,
|
||||
# python3 and luajit for shot, play, drive, dev and check.
|
||||
# Moon Miners Defold client. Builds with bob.jar, run by the Java the Defold editor ships
|
||||
# (its packages/jdk-*), so the editor is all a build needs. The editor is looked for where it
|
||||
# usually is on Linux (~/Defold, /opt/Defold), macOS (Defold.app in /Applications or
|
||||
# ~/Applications) and Windows (~/Defold, C:/Defold, Git Bash or MSYS make); DEFOLD=<the folder
|
||||
# holding its packages> for another place, JAVA=<path> for a Java of your own. With no editor
|
||||
# found it is java (21+) from PATH. xvfb-run, Xvfb, ImageMagick, xdotool, python3 and luajit
|
||||
# for shot, play, drive, dev and check (Linux).
|
||||
DEFOLD_DIRS := $(DEFOLD) $(HOME)/Defold /opt/Defold \
|
||||
/Applications/Defold.app/Contents/Resources $(HOME)/Applications/Defold.app/Contents/Resources \
|
||||
$(USERPROFILE)/Defold C:/Defold
|
||||
# (make's own wildcard and sort, no shell tools; the last after sorting is the newest)
|
||||
DEFOLD_JAVA := $(lastword $(sort $(foreach d,$(DEFOLD_DIRS),$(wildcard \
|
||||
$(d)/packages/jdk*/bin/java $(d)/packages/jdk*/bin/java.exe $(d)/packages/jdk*/Contents/Home/bin/java))))
|
||||
JAVA ?= $(or $(DEFOLD_JAVA),java)
|
||||
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
|
||||
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
|
||||
BOB := tools/bob.jar
|
||||
@@ -18,25 +30,26 @@ $(BOB):
|
||||
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
|
||||
|
||||
resolve: $(BOB)
|
||||
java -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
|
||||
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
|
||||
.internal/lib/dependencies.json: game.project | $(BOB)
|
||||
java -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
touch $@
|
||||
|
||||
build: $(BOB) .internal/lib/dependencies.json
|
||||
java -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
|
||||
$(JAVA) -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
|
||||
--archive --bundle-output dist/$(PLATFORM) build bundle
|
||||
|
||||
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release
|
||||
# by default; WEB_VARIANT=debug keeps logs and the test hook (window.mmTestQueue).
|
||||
# `make serve-web` serves it on :8080 with /api and /static proxied to UPSTREAM.
|
||||
# The game server serves it at / (server.go, MM_WEB_DIR); `make serve-web` serves it on :8080
|
||||
# with /api and /static proxied to UPSTREAM (a test server).
|
||||
WEB_VARIANT ?= release
|
||||
UPSTREAM ?= http://localhost:3000
|
||||
WEB_PORT ?= 8080
|
||||
web: $(BOB) .internal/lib/dependencies.json
|
||||
java -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
|
||||
$(JAVA) -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
|
||||
--archive --bundle-output dist/web build bundle
|
||||
|
||||
serve-web:
|
||||
@@ -50,7 +63,7 @@ dev: build
|
||||
|
||||
reload: $(BOB)
|
||||
@mkdir -p dist/dev
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|
||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
|
||||
@scripts/dev.py reload
|
||||
|
||||
@@ -63,7 +76,7 @@ SCENARIO ?= smoke
|
||||
DRIVE ?= --fixture
|
||||
drive: $(BOB) .internal/lib/dependencies.json
|
||||
@mkdir -p dist
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
||||
scripts/drive.py $(DRIVE) $(SCENARIO)
|
||||
|
||||
@@ -71,7 +84,7 @@ drive: $(BOB) .internal/lib/dependencies.json
|
||||
# dist/drive/lint-<size>/. Needs MM_API (a test server).
|
||||
lint: $(BOB) .internal/lib/dependencies.json
|
||||
@mkdir -p dist
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
||||
@for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done
|
||||
|
||||
@@ -79,7 +92,7 @@ lint: $(BOB) .internal/lib/dependencies.json
|
||||
# scenario run (sections) against MM_API (required: a test server) if it answers.
|
||||
check: $(BOB) .internal/lib/dependencies.json
|
||||
@mkdir -p dist
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|
||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
|
||||
scripts/check.py
|
||||
|
||||
@@ -90,11 +103,13 @@ run: build
|
||||
|
||||
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
|
||||
shot: build
|
||||
scripts/shot.sh $(BIN) $(SHOT) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
||||
scripts/shot.sh $(BIN) $(SHOT) $(if $(MM_API),--config=mm.api_base=$(MM_API)) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
||||
|
||||
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
|
||||
# Needs MM_API (a test server): it makes a new player there.
|
||||
play: build
|
||||
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
|
||||
@test -n "$(MM_API)" || { echo "make play: set MM_API to a test server (no default)"; exit 1; }
|
||||
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.api_base=$(MM_API) --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
|
||||
|
||||
clean:
|
||||
rm -rf build dist .internal/cache
|
||||
@@ -6,9 +6,12 @@
|
||||
-- local l = list.new(g, parent, x, y, w, h, {
|
||||
-- key = "moons", row_h = 64, -- the row's slot (gap included)
|
||||
-- create = function(item, index) ... return root[, druid component] end,
|
||||
-- focus_id = function(item) return "moon:" .. item.id end }) -- (optional) the test
|
||||
-- focus_id = function(item) return "moon:" .. item.id end, -- (optional) the test
|
||||
-- -- id its row registers: Tab walks
|
||||
-- -- every row, scrolling (modules/focus)
|
||||
-- on_remove = function(node, item) ... end }) -- (optional) a row scrolled out, before
|
||||
-- -- its node is deleted (not when the
|
||||
-- -- list itself goes in a rebuild)
|
||||
-- l:set_data(items) -- (again with a filtered list: the rows are made again)
|
||||
--
|
||||
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
|
||||
@@ -43,6 +46,13 @@ function M.new(g, parent, x, y, w, h, opts)
|
||||
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
|
||||
return l.create(item, index)
|
||||
end)
|
||||
if opts.on_remove then
|
||||
l.data.on_element_remove:subscribe(function(_, _, node, _, item)
|
||||
if not g.tearing_down then
|
||||
opts.on_remove(node, item)
|
||||
end
|
||||
end)
|
||||
end
|
||||
l.scroll.on_scroll:subscribe(function(_, pos)
|
||||
if g.tearing_down then
|
||||
return -- (the old list emptying itself during a rebuild: keep the offset)
|
||||
@@ -55,15 +65,16 @@ function M.new(g, parent, x, y, w, h, opts)
|
||||
end
|
||||
|
||||
function M:set_data(items)
|
||||
local saved = self.g.scroll_pos[self.key] or 0 -- (setting the data scrolls to the top)
|
||||
local saved = self.g.scroll_pos[self.key] or 0
|
||||
self.items = items
|
||||
self.data:set_data(items)
|
||||
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the
|
||||
-- scroll event).
|
||||
-- At the saved offset first (Druid clamps it to the rows), so the data list makes the
|
||||
-- rows there, not the top ones only to drop them again.
|
||||
local s = self.scroll
|
||||
s:set_size(self.grid:get_size_for(#items))
|
||||
s.target_position.y = saved
|
||||
s:_set_scroll_position(s.position.x, saved)
|
||||
s.target_position.y = s.position.y
|
||||
self.data:set_data(items)
|
||||
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
|
||||
for _, a in ipairs(self.g.scroll_areas) do
|
||||
if a.list == self then
|
||||
|
||||
@@ -36,9 +36,50 @@ end
|
||||
|
||||
---------------------------------------------------------------- signatures
|
||||
|
||||
local function ser(v, buf)
|
||||
-- Frozen tables (M.freeze: a state's parts, which nothing changes once shown) are written
|
||||
-- as a token of their content, worked out once per table: two tables with the same
|
||||
-- content get the same token, so a block whose inputs the server's answer left as the
|
||||
-- prediction had them is kept, and a list as long as the moons is serialized once per
|
||||
-- state, not once per block and build. (A frozen table must not change.)
|
||||
local frozen = setmetatable({}, { __mode = "k" }) -- table -> true (not worked out yet) or its token
|
||||
local tokens, token_bytes, token_n = {}, 0, 0 -- content -> token; the content strings' size
|
||||
local TOKEN_BYTES = 4 * 1024 * 1024 -- (then forgotten: equal content gets a new token once)
|
||||
|
||||
local ser
|
||||
|
||||
local function token_of(v)
|
||||
local tok = frozen[v]
|
||||
if tok ~= true then
|
||||
return tok
|
||||
end
|
||||
local buf = {}
|
||||
frozen[v] = false -- (while it's written: a table inside itself is written out)
|
||||
ser(v, buf, true)
|
||||
local s = table.concat(buf)
|
||||
tok = tokens[s]
|
||||
if not tok then
|
||||
if token_bytes > TOKEN_BYTES then
|
||||
tokens, token_bytes = {}, 0
|
||||
end
|
||||
token_n = token_n + 1
|
||||
tok = "#" .. token_n
|
||||
tokens[s], token_bytes = tok, token_bytes + #s
|
||||
end
|
||||
frozen[v] = tok
|
||||
return tok
|
||||
end
|
||||
|
||||
local function key_lt(a, b)
|
||||
return tostring(a) < tostring(b)
|
||||
end
|
||||
|
||||
ser = function(v, buf, own)
|
||||
local t = type(v)
|
||||
if t == "table" then
|
||||
if not own and frozen[v] then
|
||||
buf[#buf + 1] = token_of(v)
|
||||
return
|
||||
end
|
||||
buf[#buf + 1] = "{"
|
||||
local n = #v
|
||||
for i = 1, n do
|
||||
@@ -55,7 +96,7 @@ local function ser(v, buf)
|
||||
end
|
||||
if keys then
|
||||
if mixed then
|
||||
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
|
||||
table.sort(keys, key_lt)
|
||||
else
|
||||
table.sort(keys)
|
||||
end
|
||||
@@ -68,7 +109,9 @@ local function ser(v, buf)
|
||||
end
|
||||
buf[#buf + 1] = "}"
|
||||
elseif t == "string" then
|
||||
buf[#buf + 1] = #v .. ":" .. v
|
||||
buf[#buf + 1] = #v
|
||||
buf[#buf + 1] = ":"
|
||||
buf[#buf + 1] = v
|
||||
elseif t == "function" then
|
||||
buf[#buf + 1] = "f"
|
||||
else
|
||||
@@ -76,7 +119,8 @@ local function ser(v, buf)
|
||||
end
|
||||
end
|
||||
|
||||
-- A string that is the same exactly when the values are (tables by content, keys sorted).
|
||||
-- A string that is the same exactly when the values are (tables by content, keys sorted;
|
||||
-- frozen ones by their token, the same for the same content).
|
||||
function M.sig(...)
|
||||
local buf = {}
|
||||
for i = 1, select("#", ...) do
|
||||
@@ -86,6 +130,23 @@ function M.sig(...)
|
||||
return table.concat(buf)
|
||||
end
|
||||
|
||||
-- Freeze the tables in t (a state: its lists and records, one level down; t itself too)
|
||||
-- for M.sig: nothing may change them afterwards (a prediction changes its copy before).
|
||||
function M.freeze(t)
|
||||
if type(t) ~= "table" then
|
||||
return t
|
||||
end
|
||||
if frozen[t] == nil then
|
||||
frozen[t] = true
|
||||
end
|
||||
for _, v in pairs(t) do
|
||||
if type(v) == "table" and frozen[v] == nil then
|
||||
frozen[v] = true
|
||||
end
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- registrations
|
||||
|
||||
-- Note a registration made while building (fn(...) makes it again in a later build that
|
||||
@@ -216,6 +277,37 @@ local function rezone(b)
|
||||
end
|
||||
end
|
||||
|
||||
-- After a kept block is taken: its Druid components (and those of the blocks inside it)
|
||||
-- get new uids, in their order, as if made now. Druid hands input to the newest first
|
||||
-- (by uid: druid_instance's sort_input_comparator), so a kept button inside a box rebuilt
|
||||
-- around it (a slot in a well card, which selects the well when clicked) still gets its
|
||||
-- clicks first, as a rebuilt one would.
|
||||
local new_uid = require("druid.component").create_uid
|
||||
local function renew(c)
|
||||
local cc = c._component
|
||||
if cc and cc._uid then
|
||||
cc._uid = new_uid()
|
||||
cc._is_input_priority_changed = true -- (the instance sorts its input stack again)
|
||||
end
|
||||
local kids = c.get_childrens and c:get_childrens() or {}
|
||||
for i = 1, #kids do -- (already the whole subtree, in its order)
|
||||
local k = kids[i]._component
|
||||
if k and k._uid then
|
||||
k._uid = new_uid()
|
||||
end
|
||||
end
|
||||
end
|
||||
local function reorder(b)
|
||||
for _, c in ipairs(b.comps) do
|
||||
pcall(renew, c)
|
||||
end
|
||||
for _, e in ipairs(b.journal) do
|
||||
if e.block and not e.block.dead then
|
||||
reorder(e.block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- True while a kept block's registrations are made again (test.register looks for its
|
||||
-- clip zone again then: the one it was given may have gone with the old parent).
|
||||
function M.replaying()
|
||||
@@ -318,6 +410,7 @@ function M.open(g, key, sig, parent)
|
||||
reg.used[key] = true
|
||||
gui.set_parent(old.root, parent, false)
|
||||
rezone(old)
|
||||
reorder(old)
|
||||
adopt(reg, old)
|
||||
return old, true
|
||||
end
|
||||
|
||||
@@ -247,12 +247,16 @@ end
|
||||
|
||||
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
|
||||
-- the item before / after the focused row (or the first / last when coming from outside),
|
||||
-- scrolled into view; the rebuild makes its row, which then has the focus. Returns true
|
||||
-- when it moved the focus.
|
||||
-- scrolled into view; the rebuild makes its row, which then has the focus. Within the rows
|
||||
-- in view Tab goes in order (a row's own buttons too); from a button inside a row it steps
|
||||
-- from that row (the one under it). Returns true when it moved the focus.
|
||||
local function list_step(g, f, cur, e, dir, scope_)
|
||||
local a = list_of(g, cur)
|
||||
local items, i
|
||||
if a then
|
||||
if list_of(g, e) == a then
|
||||
return false -- (the next element is in view in the same list)
|
||||
end
|
||||
items = a.list.items
|
||||
local id = cur.base:sub(#scope_ + 2)
|
||||
for n, item in ipairs(items) do
|
||||
@@ -260,6 +264,11 @@ local function list_step(g, f, cur, e, dir, scope_)
|
||||
i = n + dir
|
||||
end
|
||||
end
|
||||
if not i and a.list.row_h then
|
||||
local r, v = M.rect(cur.node), M.rect(a.view)
|
||||
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
|
||||
i = math.floor((r.y + r.h / 2 - v.y + pos) / a.list.row_h) + 1 + dir
|
||||
end
|
||||
if not i or i < 1 or i > #items then
|
||||
return false -- (leaving the list: the next element on the page)
|
||||
end
|
||||
|
||||
+19
-1
@@ -1015,9 +1015,27 @@ function M:batch_ready(g, jobs)
|
||||
end
|
||||
end
|
||||
|
||||
-- (a text node's position is its pivot point and its size not its text's: the box is the
|
||||
-- text's own, round that point)
|
||||
local PIVOT = { -- (fraction of the box left of / above the pivot point)
|
||||
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
|
||||
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
|
||||
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
|
||||
}
|
||||
|
||||
function M:box_of(key)
|
||||
local b = self.binds[key]
|
||||
return b and self:rect_of(b.box or b.node)
|
||||
if not b then
|
||||
return nil
|
||||
end
|
||||
if b.box or gui.get_type(b.node) ~= gui.TYPE_TEXT then
|
||||
return self:rect_of(b.box or b.node)
|
||||
end
|
||||
local r = self:rect_of(b.node)
|
||||
local k = PIVOT[gui.get_pivot(b.node)] or { 0, 0 }
|
||||
local w = ui.text_width(b.node)
|
||||
local h = r.h * gui.get_scale(b.node).y
|
||||
return { x = r.x - k[1] * w, y = r.y - k[2] * h, w = w, h = h }
|
||||
end
|
||||
|
||||
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
||||
|
||||
@@ -38,6 +38,7 @@ local M = {}
|
||||
M.id = nil
|
||||
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
|
||||
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
|
||||
M.covered = false -- a dialog is open over the page: the viewport takes no drag or wheel
|
||||
|
||||
-- Settings: nil until loaded (or failed: then the defaults). Replace the table to change
|
||||
-- them (moon.script compares by identity; the moon lab does, and Save sets the saved ones).
|
||||
|
||||
+35
-16
@@ -582,17 +582,17 @@ end
|
||||
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
|
||||
-- count goes up (the server's LastWell).
|
||||
M.register("claim", function(st, p, ctx)
|
||||
if p["claim-wide"] then
|
||||
return false
|
||||
end
|
||||
local list = {}
|
||||
for _, s in ipairs(st.stakes) do
|
||||
local wide = (s.reach or 1) > 1
|
||||
if done(s, ctx) and (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
|
||||
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then
|
||||
if wide then
|
||||
return false -- multi-well drills: the server splits them
|
||||
end
|
||||
local scope
|
||||
if p["claim-wide"] then
|
||||
scope = wide and s.moonId == p["claim-wide"]
|
||||
else
|
||||
scope = (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
|
||||
and (not p["claim-moon"] or s.moonId == p["claim-moon"])
|
||||
end
|
||||
if done(s, ctx) and scope then
|
||||
list[#list + 1] = s
|
||||
end
|
||||
end
|
||||
@@ -600,14 +600,33 @@ M.register("claim", function(st, p, ctx)
|
||||
return false
|
||||
end
|
||||
-- Oldest first, each from what the earlier claims left (claimInTx: at most the supply).
|
||||
-- A multi-well drill takes from its well, then the moon's next ones (split).
|
||||
table.sort(list, function(a, b) return a.start < b.start end)
|
||||
local emptied = {}
|
||||
for _, s in ipairs(list) do
|
||||
local take = math.max(0, math.min(s.amount or s.take or 0, M.supply(st, s.wellId)))
|
||||
M.add_item(st, s.resource, take * M.skew)
|
||||
M.add_supply(st, s.wellId, -take)
|
||||
if take > 0 and M.supply(st, s.wellId) <= 0 then
|
||||
emptied[s.wellId] = true
|
||||
local moon = find(st.moons, s.moonId)
|
||||
if (s.reach or 1) > 1 and moon then
|
||||
local sup = {}
|
||||
for i, w in ipairs(moon.wells or {}) do
|
||||
sup[i] = M.supply(st, w.id)
|
||||
end
|
||||
for i, take in ipairs(split(moon, s, sup)) do
|
||||
local w = moon.wells[i]
|
||||
if take > 0 then
|
||||
M.add_item(st, w.resource or s.resource, take * M.skew)
|
||||
M.add_supply(st, w.id, -take)
|
||||
if M.supply(st, w.id) <= 0 then
|
||||
emptied[w.id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
local take = math.max(0, math.min(s.amount or s.take or 0, M.supply(st, s.wellId)))
|
||||
M.add_item(st, s.resource, take * M.skew)
|
||||
M.add_supply(st, s.wellId, -take)
|
||||
if take > 0 and M.supply(st, s.wellId) <= 0 then
|
||||
emptied[s.wellId] = true
|
||||
end
|
||||
end
|
||||
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
|
||||
end
|
||||
@@ -633,7 +652,7 @@ M.register("claim", function(st, p, ctx)
|
||||
end
|
||||
local amount = tool and w and (group and group.yield or tool.yield * yield_boost(st, w.resource))
|
||||
-- Not on a drained or fully committed well (the server leaves the tool idle).
|
||||
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, 1) or 1) > EPS then
|
||||
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, tool.reach or 1) or 1) > EPS then
|
||||
M.add_stake(st, tool, s.moonId, w, ctx, group)
|
||||
local detail = st.moon and find(st.moon.wells, s.wellId)
|
||||
if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
|
||||
@@ -664,7 +683,7 @@ M.register("stake", function(st, p, ctx)
|
||||
return false -- "Deploy there": the view moves to the other well, wait for it
|
||||
end
|
||||
local tool = M.idle_copy(st, p.tool)
|
||||
if not tool or (tool.reach or 1) > 1 then
|
||||
if not tool then
|
||||
return false
|
||||
end
|
||||
local group = nil
|
||||
@@ -677,7 +696,7 @@ M.register("stake", function(st, p, ctx)
|
||||
end
|
||||
-- Refused when the drills on the well take all it has left (fullyCommitted).
|
||||
local amount = group and group.yield or tool.yield * yield_boost(st, w.resource)
|
||||
if (M.room(st, w.id, amount, 1) or 0) <= EPS then
|
||||
if (M.room(st, w.id, amount, tool.reach or 1) or 0) <= EPS then
|
||||
return false
|
||||
end
|
||||
M.add_stake(st, tool, moon.id, w, ctx, group)
|
||||
|
||||
@@ -96,7 +96,13 @@ function M.register(id, node, kind, label, zone, no_focus)
|
||||
if not M.enabled or not id then
|
||||
return
|
||||
end
|
||||
if elements[id] then
|
||||
local old = elements[id]
|
||||
if old and not pcall(gui.get_id, old.node) then
|
||||
-- (its node went: a long list's row scrolled out and back keeps its plain id)
|
||||
old.node, old.kind, old.label, old.zone, old.scope = node, kind or "button", label, zone, scope
|
||||
return id
|
||||
end
|
||||
if old then
|
||||
local n = 2
|
||||
while elements[id .. "#" .. n] do
|
||||
n = n + 1
|
||||
|
||||
@@ -605,7 +605,7 @@ function update(self, dt)
|
||||
end
|
||||
|
||||
function on_input(self, action_id, action)
|
||||
local rect = moon.frame.visible and moon.rect
|
||||
local rect = moon.frame.visible and not moon.covered and moon.rect
|
||||
if not rect then
|
||||
self.dragging = false
|
||||
return false
|
||||
|
||||
@@ -22,6 +22,8 @@ local chip = require("components.chip")
|
||||
local images = require("modules.images")
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
local blocks = require("modules.blocks")
|
||||
local anim = require("modules.anim")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
@@ -422,10 +424,83 @@ local function caption(c, x, y, inner, title, text)
|
||||
}), x, y), c)
|
||||
end
|
||||
|
||||
-- The facts under the viewport on the Moons section, built into root (at where.root's
|
||||
-- origin; where: what M.build returned with the panel): the selected well's panel, the moon's status, or a mined-out caption. A
|
||||
-- block of their own (core/main.lua), kept while M.facts_sig stays.
|
||||
function M.facts(g, root, where, H)
|
||||
local st = g.state
|
||||
local moon = st.moon
|
||||
local top, w, h, pad, inner = where.top, where.w, where.h, where.pad, where.inner
|
||||
if st.well then
|
||||
-- The well panel scrolls under the viewport (many drills).
|
||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
|
||||
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
|
||||
elseif moon.finished then
|
||||
caption(root, pad, where.cy, inner, moon.name, "Mined out. Nothing left to dig.")
|
||||
else
|
||||
-- (scrolls like the well panel: Sell moon at its foot)
|
||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
|
||||
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
|
||||
end
|
||||
end
|
||||
|
||||
-- What M.facts shows (its block's signature, with where): from the state and our clock.
|
||||
function M.facts_sig(g, where, H)
|
||||
local st = g.state
|
||||
local moon = st.moon
|
||||
local now = H.now(g)
|
||||
local sale = st.unlocked.store and moon.sale or false
|
||||
local hold = sale and sale.holdEnd and sale.holdEnd > now and math.floor((sale.holdEnd - now) / 1000) or false
|
||||
local common = { where.top, where.cy, where.w, where.h, where.inner, moon.id, moon.name, moon.finished or false,
|
||||
sale, hold, g.sale_open and g.sale_open[moon.id] or false, g.ask and g.ask.id or false, st.catalog or false, st.catalogVersion or false }
|
||||
if moon.finished then
|
||||
return blocks.sig(common)
|
||||
end
|
||||
local supplies = {}
|
||||
for i, x in ipairs(moon.wells or {}) do
|
||||
supplies[i] = x.supply
|
||||
end
|
||||
if st.well then
|
||||
local w = st.well
|
||||
local wrow = find(moon.wells, w.id) or false
|
||||
local tools = {}
|
||||
for i, d in ipairs(wrow and wrow.drills or {}) do
|
||||
local t = find(st.tools, d.toolId)
|
||||
tools[i] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
|
||||
end
|
||||
local wide = wrow and #(wrow.wide or {}) > 0 and moon.wide or false
|
||||
for _, d in ipairs(wide and wide.drills or {}) do
|
||||
local t = find(st.tools, d.toolId)
|
||||
tools[#tools + 1] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
|
||||
end
|
||||
local better = w.better or false
|
||||
local bt = better and find(st.tools, better.toolId)
|
||||
local bm = better and find(st.moons, better.moonId)
|
||||
return blocks.sig(common, w.id, w.resource, w.rate, better, bt and { bt.name or false, ui.levels_tag(bt) } or false,
|
||||
bm and bm.name or false, wrow, wide, wide and moon.wells or false, tools, st.bonuses or false, moon.maxSupply or false,
|
||||
st.rules and st.rules.maxSupply or false, moons.supply_max(st, moon), anim.reduced)
|
||||
end
|
||||
local mining, done, idle = 0, 0, 0
|
||||
for _, s in ipairs(st.stakes or {}) do
|
||||
if s.moonId == moon.id then
|
||||
if s.ready or (s["end"] or math.huge) <= now then
|
||||
done = done + 1
|
||||
else
|
||||
mining = mining + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, t in ipairs(st.tools or {}) do
|
||||
idle = idle + (t.stakeId and 0 or 1)
|
||||
end
|
||||
return blocks.sig(common, supplies, mining, done, idle)
|
||||
end
|
||||
|
||||
-- Region contract (AGENTS.md, "Game screen"): builds the aside panel at (x, y), w x h.
|
||||
-- mod: the current section's module (its aside / aside_below, if any). row: the narrow
|
||||
-- layout's row (core/main.lua): the viewport on the left, the side panel beside it.
|
||||
-- Returns the panel (components/panel: its entrance).
|
||||
-- Returns the panel (components/panel: its entrance) and, on the Moons section with a
|
||||
-- moon, where its facts go (M.facts).
|
||||
function M.build(g, parent, x, y, w, h, H, mod, row)
|
||||
local st = g.state
|
||||
local p = panel.new(parent, x, y, w, h)
|
||||
@@ -435,7 +510,13 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
|
||||
local section = g.view.section
|
||||
local moon = section == "moons" and st.moon or nil
|
||||
local cy
|
||||
if row then
|
||||
-- A section panel that wants the whole aside (the drill panel): no moon over it.
|
||||
local full = mod and mod.aside_full and st[section] and mod.aside_full(g)
|
||||
if full then
|
||||
moon_view.hide()
|
||||
g.viewport_node, g.viewport_rect = nil, nil
|
||||
cy = 8
|
||||
elseif row then
|
||||
local vp = M.row_viewport(w)
|
||||
viewport(g, root, pad, pad, vp, shown_moon(g))
|
||||
-- The side panel gets the rest of the row as its own box.
|
||||
@@ -468,18 +549,10 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
|
||||
end
|
||||
gui.delete_node(box)
|
||||
end
|
||||
if moon and st.well then
|
||||
-- The well panel scrolls under the viewport (many drills).
|
||||
local top = cy - 8
|
||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
|
||||
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
|
||||
elseif moon and moon.finished then
|
||||
caption(root, pad, cy, inner, moon.name, "Mined out. Nothing left to dig.")
|
||||
elseif moon then
|
||||
-- (scrolls like the well panel: Sell moon at its foot)
|
||||
local top = cy - 8
|
||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
|
||||
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
|
||||
if moon then
|
||||
-- The moon's facts are a block of their own (M.facts, core/main.lua): a change the
|
||||
-- viewport doesn't show keeps the panel and its moon.
|
||||
return p, { root = root, top = cy - 8, cy = cy, w = w, h = h, pad = pad, inner = inner }
|
||||
elseif mod and mod.aside_below and st[section] then
|
||||
mod.aside_below(g, root, pad, cy, inner, limit, H)
|
||||
elseif section == "new-moon" then
|
||||
|
||||
@@ -146,9 +146,9 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
|
||||
if g.autoplay then
|
||||
local p = type(params) == "function" and params() or params
|
||||
g.autoplay:register(name, p, fire)
|
||||
blocks.note(function() -- (a kept block registers again)
|
||||
blocks.note(function() -- (a kept block registers again; params made now)
|
||||
if g.autoplay then
|
||||
g.autoplay:register(name, p, fire)
|
||||
g.autoplay:register(name, type(params) == "function" and params() or params, fire)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -283,14 +283,25 @@ local function build_main(g, parent, x, y, w, h)
|
||||
end
|
||||
|
||||
-- The aside's block (narrow: the row under the panels).
|
||||
local function build_aside(g, parent, x, y, w, h, row, common)
|
||||
local function build_aside(g, parent, x, y, w, h, row, common, still)
|
||||
local st = g.state
|
||||
local section = g.view.section
|
||||
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
|
||||
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
|
||||
return blocks.keep(g, "aside", sig, parent, function(holder)
|
||||
-- (on Moons with a moon the panel only shows the viewport: its facts are a block of
|
||||
-- their own, below)
|
||||
local moon_only = section == "moons" and st.moon ~= nil
|
||||
local sig = blocks.sig(moon_only and still or common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
|
||||
moon_only and blocks.sig(st.moon.id, st.view and st.view.aside, st.catalogVersion or st.catalog or false)
|
||||
or pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
|
||||
local p, facts = blocks.keep(g, "aside", sig, parent, function(holder)
|
||||
return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
|
||||
end)
|
||||
if facts then
|
||||
-- The moon's facts: a block of their own inside the panel (aside.facts).
|
||||
blocks.keep(g, "aside:facts", aside.facts_sig(g, facts, g.h), facts.root, function(holder)
|
||||
aside.facts(g, holder, facts, g.h)
|
||||
end)
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
-- A full rebuild drops every node and component and builds them all again; otherwise the
|
||||
@@ -360,11 +371,15 @@ function M.build(g)
|
||||
g.layout_state = true
|
||||
local st = g.state
|
||||
local common = local_sig(g)
|
||||
-- (without the drills and batches that ended on our clock: the banner, header and moon
|
||||
-- list show those counts through their own signatures, and a drill ending mustn't
|
||||
-- rebuild them)
|
||||
local still = local_sig(g, {})
|
||||
-- Announcements and live events above the header.
|
||||
local banner_h = blocks.keep(g, "banner", blocks.sig(common, W, st.banner), g.layout, function(holder)
|
||||
local banner_h = blocks.keep(g, "banner", blocks.sig(still, W, st.banner), g.layout, function(holder)
|
||||
return banner.build(g, g.h, holder, W)
|
||||
end)
|
||||
local header_h = blocks.keep(g, "header", blocks.sig(common, W, banner_h, g.view.section, st.credits, st.resources,
|
||||
local header_h = blocks.keep(g, "header", blocks.sig(still, W, banner_h, g.view.section, st.credits, st.resources,
|
||||
st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
|
||||
g.tab_nodes = {}
|
||||
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
|
||||
@@ -438,8 +453,8 @@ function M.build(g)
|
||||
for _, m in ipairs(st.moons) do
|
||||
hides[#hides + 1] = g.reveal:hides(m.id) or false
|
||||
end
|
||||
blocks.keep(g, "moonlist", blocks.sig(common, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
|
||||
st.moons, st.past, st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
|
||||
blocks.keep(g, "moonlist", blocks.sig(still, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
|
||||
moonlist.rows_sig(g), st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
|
||||
st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder)
|
||||
g.moon_nodes = {}
|
||||
moonlist.teardown(g)
|
||||
@@ -462,7 +477,7 @@ function M.build(g)
|
||||
end
|
||||
end
|
||||
if page then
|
||||
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common)
|
||||
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common, still)
|
||||
local s = page:finish(y0 + body_h + gap + row_h + pad)
|
||||
if g.viewport_node and not g.reveal:active() then
|
||||
moon_view.move(g.viewport_node) -- (where the page's offset put it)
|
||||
@@ -487,7 +502,7 @@ function M.build(g)
|
||||
end)
|
||||
end
|
||||
elseif has_aside then
|
||||
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common)
|
||||
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common, still)
|
||||
else
|
||||
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
||||
if not g.reveal:active() then -- (the reveal's window shows the moon)
|
||||
@@ -670,6 +685,8 @@ function M.update(g, dt)
|
||||
g.fx:update(dt)
|
||||
g.reveal:update(dt)
|
||||
g.dialog:update(dt)
|
||||
-- (moon.script takes input before the GUI: a menu over the viewport must get its clicks)
|
||||
moon_view.covered = g.dialog:is_open()
|
||||
cards.ask_update(g)
|
||||
check_input(g)
|
||||
tooltip.update(g.dialog:is_open())
|
||||
|
||||
@@ -15,6 +15,7 @@ local predict = require("modules.predict")
|
||||
local sfx = require("modules.sfx")
|
||||
local refinery = require("screens.game.refinery")
|
||||
local sidepanel = require("screens.game.sidepanel")
|
||||
local blocks = require("modules.blocks")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -190,7 +191,8 @@ end
|
||||
|
||||
-- What we show: the last server state (g.base) with the pending predictions on top.
|
||||
local function show(g)
|
||||
g.state = predict.apply(g.base, g.pending)
|
||||
-- (frozen: what is shown never changes, so blocks.sig writes each part once per state)
|
||||
g.state = blocks.freeze(predict.apply(g.base, g.pending))
|
||||
g.view = g.state.view
|
||||
arm_refetch(g)
|
||||
g.dirty = true
|
||||
@@ -460,7 +462,7 @@ function M.act(g, name, params, on_done, fn)
|
||||
if predict.run(st, e) then
|
||||
g.pending[#g.pending + 1] = e
|
||||
local prev = g.state
|
||||
g.state = st
|
||||
g.state = blocks.freeze(st)
|
||||
g.dirty = true
|
||||
predicted = true
|
||||
-- The action's effects play now, from the predicted change (fx.lua).
|
||||
|
||||
@@ -12,6 +12,7 @@ local ui = require("modules.ui")
|
||||
local tooltip = require("modules.tooltip")
|
||||
local cards = require("screens.game.cards")
|
||||
local scroll = require("components.scroll")
|
||||
local list = require("components.list")
|
||||
local store = require("screens.game.store")
|
||||
local row_c = require("components.row")
|
||||
local blocks = require("modules.blocks")
|
||||
@@ -38,6 +39,13 @@ end
|
||||
|
||||
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||
|
||||
-- A tool's track's ladder: state.equipment.ladders holds each once per store item and
|
||||
-- levels, the row names its own (ladderKey).
|
||||
local function ladder_of(st, t, tr)
|
||||
local l = (st.equipment.ladders or {})[t.ladderKey or ""]
|
||||
return l and l[tr.kind] or {}
|
||||
end
|
||||
|
||||
-- Stats: Yield and Cycle (a stake's now), wrapping (rows 20 tall, 16 apart). A tree piece.
|
||||
local function stats_box(st, parent, t)
|
||||
local items = { { "Yield", ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) } }
|
||||
@@ -89,7 +97,7 @@ local function track_box(self, H, st, parent, t, tr, w)
|
||||
-- this width), so the box (and the card) stays the same height from level to level.
|
||||
local parts_h = 0
|
||||
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
|
||||
for _, r in ipairs(tr.ladder or {}) do
|
||||
for _, r in ipairs(ladder_of(st, t, tr)) do
|
||||
if #(r.parts or {}) > 0 then
|
||||
parts_h = math.max(parts_h, cards.parts(st, probe, r.parts, 0, 0, w))
|
||||
end
|
||||
@@ -167,8 +175,7 @@ local function geometry(inner)
|
||||
return {
|
||||
wide = wide,
|
||||
gap = wide and 12 or 10,
|
||||
media_w = wide and 48 or 40, media_h = wide and 40 or 34,
|
||||
status = wide and math.min(230, math.floor(inner * 0.22)) or 0,
|
||||
status = wide and math.min(200, math.floor(inner * 0.16)) or 0,
|
||||
track = wide and 124 or 90,
|
||||
sell = wide and 150 or 60,
|
||||
name_px = wide and 14 or 13,
|
||||
@@ -382,14 +389,17 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
|
||||
name.done = function(n)
|
||||
tooltip.add(n, t.name .. ": yield level " .. (t.yieldLevel or 0) .. ", cycle level " .. (t.cycleLevel or 0))
|
||||
end
|
||||
local rh = row_h(open, wide)
|
||||
-- The picture, square, takes the row's whole height left of every line (narrow: at most 64).
|
||||
local mh = wide and rh - PADY * 2 or math.min(rh - PADY * 2, 64)
|
||||
local mw = mh
|
||||
local media = made({ width = mw, height = mh, shrink = 0 }, function(p, x, y)
|
||||
local m = ui.rect(p, x, y, mw, mh, "bg")
|
||||
ui.outline(m, 0, 0, mw, mh, "line")
|
||||
cards.image(m, t.image, (mw - mh) / 2, 0, mh)
|
||||
end)
|
||||
local lines = {
|
||||
row({ height = LINE1, gap = gap, align = "center" }, {
|
||||
made({ width = geo.media_w, height = geo.media_h, shrink = 0 }, function(p, x, y)
|
||||
local mw, mh = geo.media_w, geo.media_h
|
||||
local m = ui.rect(p, x, y, mw, mh, "bg")
|
||||
ui.outline(m, 0, 0, mw, mh, "line")
|
||||
cards.image(m, t.image, (mw - mh) / 2, 0, mh)
|
||||
end),
|
||||
col({ grow = 1, shrink = 1, basis = 0 }, {
|
||||
name,
|
||||
fg.label(c, tagline, { font = "mono", px = 12, color = "muted" }, 16, { fit = true }),
|
||||
@@ -443,9 +453,10 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
|
||||
lines[#lines + 1] = space(0, 6)
|
||||
lines[#lines + 1] = row({ height = 20, align = "center" }, { left })
|
||||
end
|
||||
local rh = row_h(open, wide)
|
||||
local selected = t.selected and self.view.drill ~= ""
|
||||
return col({ width = w, height = rh, padding = { PADY, PADX, PADY, PADX } }, lines, { make = function(p, x, y, bw, bh)
|
||||
return row({ width = w, height = rh, gap = gap, padding = { PADY, PADX, PADY, PADX } }, {
|
||||
media, col({ grow = 1, shrink = 1, basis = 0 }, lines),
|
||||
}, { make = function(p, x, y, bw, bh)
|
||||
local slot = ui.rect(p, x, y, bw, bh, "none")
|
||||
local body = ui.rect(slot, 0, 0, bw, bh, "cyan", 0)
|
||||
row_c.bind(self, body, bw, bh, { selected = selected, id = "drill:" .. t.id, label = t.name, on_click = function()
|
||||
@@ -456,12 +467,34 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
|
||||
end })
|
||||
end
|
||||
|
||||
-- What a row shows besides its tool (t, with its tracks: their affordability is the
|
||||
-- server's canAfford/shortText, so credits and parts aren't needed): kept while it's the same.
|
||||
local function row_sig(self, st, t, copy, conf, inner, open)
|
||||
local moon = t.stake and find(st.moons, "id", t.stake.moonId)
|
||||
local busy = {} -- (its buttons waiting for the server: H.action's keys)
|
||||
for k in pairs(self.inflight or {}) do
|
||||
if k:find(t.id, 1, true) then
|
||||
busy[#busy + 1] = k
|
||||
end
|
||||
end
|
||||
table.sort(busy)
|
||||
local sid = "confirm:sell-tool:" .. t.id
|
||||
local ask = self.ask and self.ask.id == sid and self.ask.t or false
|
||||
return blocks.sig(inner, open, t, copy, conf, moon and moon.name or false, (t.reach or 1) > 1 and cards.wells(st, t.reach) or false,
|
||||
st.catalogVersion, self.view.drill ~= "", busy, ask)
|
||||
end
|
||||
|
||||
-- The heading and the column captions stay at the top; the rows are a long list
|
||||
-- (components/list) under them: only the rows in view exist (a screenful of nodes at any
|
||||
-- number of tools), each a block kept while its row_sig stays the same, so an action
|
||||
-- rebuilds only the rows it changed.
|
||||
function M.build(self, root, px, py, w, h, H)
|
||||
local st = self.state
|
||||
local eq = st.equipment
|
||||
local open = st.unlocked.store
|
||||
local area, x, inner, y = cards.page(self, root, px, py, w, h, "equipment", TABLE_MAX)
|
||||
local c = area.content
|
||||
local inner = math.min(w - px * 2, TABLE_MAX)
|
||||
local x, y = math.floor((w - inner) / 2), py - 2
|
||||
local c = ui.rect(root, 0, 2, w, h - 4, "none") -- (where cards.page's content was)
|
||||
local geo = geometry(inner)
|
||||
y = fg.flow(c, x, y, inner, { col({ width = inner }, { -- (full width: Buy tools sits at its right edge)
|
||||
fg.label(c, "Equipment", "h1", 40),
|
||||
@@ -476,26 +509,44 @@ function M.build(self, root, px, py, w, h, H)
|
||||
#eq.tools > 0 and header(c, geo) or nil,
|
||||
#eq.tools > 0 and space(0, 6) or nil,
|
||||
}) })
|
||||
if #eq.tools > 0 then
|
||||
ui.rect(c, x, y, inner, 1, "line_hi")
|
||||
y = y + 1
|
||||
if #eq.tools == 0 then
|
||||
return
|
||||
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
|
||||
seen[t.storeItemId] = (seen[t.storeItemId] or 0) + 1
|
||||
local copy = seen[t.storeItemId]
|
||||
local conf = sell_confirm(st, t)
|
||||
local moon = t.stake and find(st.moons, "id", t.stake.moonId)
|
||||
-- (kept while what it shows stays the same: modules/blocks)
|
||||
local sig = blocks.sig(x, y, inner, t, copy, conf, open, moon and moon.name or false, st.refined, st.credits,
|
||||
st.resources, st.catalogVersion, self.inflight, self.view, self.ask and self.ask.id or false)
|
||||
local ry = y
|
||||
blocks.keep(self, "eq:tool:" .. tostring(t.id), sig, c, function(r)
|
||||
fg.build(flex.solve(tool_row(self, H, c, st, t, copy, geo, inner, open, conf), x, ry), r)
|
||||
end)
|
||||
y = y + rh
|
||||
copies[t.id] = seen[t.storeItemId]
|
||||
end
|
||||
area:finish(y + py)
|
||||
local rh = row_h(open, geo.wide)
|
||||
local rows = {} -- slot node -> its row's block
|
||||
local l
|
||||
l = list.new(self, c, x, y, inner, math.max(rh, h - 4 - y - py), {
|
||||
key = "equipment", row_h = rh,
|
||||
focus_id = function(t) return "drill:" .. t.id end,
|
||||
create = function(t)
|
||||
local slot = ui.rect(l.content, 0, 0, inner, rh, "none")
|
||||
local copy, conf = copies[t.id], sell_confirm(st, t)
|
||||
local b, kept = blocks.open(self, "eq:tool:" .. tostring(t.id), row_sig(self, st, t, copy, conf, inner, open), slot)
|
||||
if not kept then
|
||||
fg.build(flex.solve(tool_row(self, H, b.root, st, t, copy, geo, inner, open, conf), 0, 0), b.root)
|
||||
end
|
||||
blocks.close(self, b)
|
||||
rows[slot] = b
|
||||
return slot
|
||||
end,
|
||||
-- A row scrolled out goes with its block (its buttons' Druid components too).
|
||||
on_remove = function(slot)
|
||||
local b = rows[slot]
|
||||
rows[slot] = nil
|
||||
if b then
|
||||
blocks.drop(self, b)
|
||||
end
|
||||
end,
|
||||
})
|
||||
l:set_data(eq.tools)
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- drill panel (aside)
|
||||
@@ -526,7 +577,7 @@ local TIER_HEAD = { font = "sans_semi", px = 10, color = "muted", tracking = 0.1
|
||||
|
||||
-- A track's ladder: a grid (Level, value, price and parts at 0, 16, 40 % of the row's
|
||||
-- inner width, ₥/h at the right), the current level and the next lit.
|
||||
local function ladder(st, c, tr, inner)
|
||||
local function ladder(st, c, t, tr, inner)
|
||||
local fr = { 0, 0.16, 0.4, 0.84 }
|
||||
local cols = {}
|
||||
for i = 1, 3 do
|
||||
@@ -541,7 +592,7 @@ local function ladder(st, c, tr, inner)
|
||||
-- (₥/h: its left edge 22 before the rate column's right edge)
|
||||
local rate_head = fg.label(c, theme.currency .. "/h", TIER_HEAD, 12, { position = "absolute", top = 0, left = inner - 30 })
|
||||
local rows = {}
|
||||
for _, r in ipairs(tr.ladder or {}) do
|
||||
for _, r in ipairs(ladder_of(st, t, tr)) do
|
||||
local color = r.current and "text" or (r.next and "stat_up" or "text_2")
|
||||
local font = r.next and "mono_semi" or "mono"
|
||||
local price = "—"
|
||||
@@ -576,6 +627,11 @@ local function ladder(st, c, tr, inner)
|
||||
return col({}, { box({}, { head, rate_head }), col({ gap = 1 }, rows), space(0, 11) })
|
||||
end
|
||||
|
||||
-- The drill panel takes the whole aside (core/aside.lua: no moon over it).
|
||||
function M.aside_full(self)
|
||||
return self.state.equipment.drill ~= nil
|
||||
end
|
||||
|
||||
function M.aside(self, root, w, h, H)
|
||||
local st = self.state
|
||||
local d = st.equipment and st.equipment.drill
|
||||
@@ -660,7 +716,7 @@ function M.aside(self, root, w, h, H)
|
||||
elseif open then
|
||||
add(space(0, 22)) -- (the line's room, kept at the top level: Sell drill stays put)
|
||||
end
|
||||
add(ladder(st, c, tr, inner))
|
||||
add(ladder(st, c, t, tr, inner))
|
||||
end
|
||||
|
||||
-- Idle copies while this one mines: deploy one on its best well.
|
||||
|
||||
@@ -273,6 +273,35 @@ function M.grade(g)
|
||||
return st.moonGrade
|
||||
end
|
||||
|
||||
-- What the rows show (widgets/moon_row: name, grade, rating, status, warning, ready
|
||||
-- badge, selection), both lists: the moon list's block signature has this instead of the
|
||||
-- moons themselves, so a change the rows don't show (a well's projection, a drill's end)
|
||||
-- keeps the list.
|
||||
function M.rows_sig(self)
|
||||
local st, v = self.state, self.view
|
||||
local out, n = {}, 0
|
||||
for _, list in ipairs({ st.moons, st.past }) do
|
||||
for _, m in ipairs(list) do
|
||||
local dry = not m.finished and #(m.wells or {}) > 0
|
||||
if dry then
|
||||
for _, w in ipairs(m.wells) do
|
||||
if w.supply > 0 then
|
||||
dry = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
n = n + 1
|
||||
out[n] = { m.id, m.name, m.grade or false, m.rating or false, m.finished or false, m.mined or false,
|
||||
m.finishedOn or false, m.finishedAt or false, dry, m.drills or 0, m.over or false,
|
||||
not m.finished and ready.drills(self, m.id) or m.ready or 0, (m.selected and v.section == "moons") or false }
|
||||
end
|
||||
n = n + 1
|
||||
out[n] = "|"
|
||||
end
|
||||
return blocks.sig(out)
|
||||
end
|
||||
|
||||
function M.build(self, H, parent, x, y, w, h)
|
||||
local st = self.state
|
||||
local v = self.view
|
||||
|
||||
+422
-253
@@ -19,6 +19,7 @@ local fg = require("modules.flex_gui")
|
||||
local images = require("modules.images")
|
||||
local test = require("modules.test")
|
||||
local predict = require("modules.predict")
|
||||
local blocks = require("modules.blocks")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
@@ -302,42 +303,95 @@ local function fit(p, str, style, opts)
|
||||
return fg.label(p, str, style, 16, opts)
|
||||
end
|
||||
|
||||
-- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a
|
||||
-- cycle gets here, or why it can't (no idle drills, no room).
|
||||
local function deploy_slot(g, st, well, groups, H)
|
||||
-- Each part of the moon (its heading, a well's card, the multi-well card, the tray) is a
|
||||
-- kept block (modules/blocks), built at its own origin and put in place: a deploy rebuilds
|
||||
-- the cards it changed, not the panel. What a part shows is worked out first (its model,
|
||||
-- from the state and our clock) and is its block's signature; the build draws only from
|
||||
-- the model and the state, so a kept part shows what a rebuilt one would.
|
||||
|
||||
-- A kept part of the moon (key, sig: its model) at (x, y) of parent: fn(root) builds it
|
||||
-- at root's origin and returns its height (also when it's kept). Parts nest: a card's
|
||||
-- slots are parts of their own, so a card that changes keeps the slots that didn't.
|
||||
local function part(g, key, sig, parent, x, y, fn)
|
||||
local b, kept = blocks.open(g, key, sig, parent)
|
||||
local h
|
||||
if kept then
|
||||
h = blocks.close(g, b)
|
||||
else
|
||||
h = blocks.close(g, b, fn(b.root))
|
||||
end
|
||||
gui.set_position(b.root, vec(x, -y))
|
||||
return h
|
||||
end
|
||||
|
||||
-- The busy and ready-pop marks (core/hooks: g.inflight, g.ready_at) of these action keys,
|
||||
-- for a part's signature.
|
||||
local function marks(g, keys)
|
||||
local out = {}
|
||||
for i, k in ipairs(keys) do
|
||||
local r = g.ready_at and g.ready_at[k]
|
||||
out[i] = (g.inflight[k] and "b" or "") .. (r and tostring(r.t) or "")
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- What a well's deploy slot shows (the group it sends and its two lines).
|
||||
local function deploy_view(g, st, well, groups)
|
||||
local grp, here, amount, explicit = deploy_choice(g, st, well, groups)
|
||||
local ok = grp ~= nil and here > EPS
|
||||
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
|
||||
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not ok })
|
||||
local title, sub, sub_color
|
||||
if #groups == 0 then
|
||||
title, sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
|
||||
elseif not grp then
|
||||
title, sub = "No room here", "The drills on it take all it has"
|
||||
elseif not ok then
|
||||
title, sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
|
||||
else
|
||||
local t = grp.tool
|
||||
local reach = reach_short(st, t.reach)
|
||||
title = "+ Deploy " .. M.short_name(t)
|
||||
sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
|
||||
if here < amount - EPS then
|
||||
sub, sub_color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
|
||||
local v = { ok = ok, explicit = explicit or false, key = grp and grp.key or false,
|
||||
name = grp and grp.tool.name or false, tool = grp and grp.tool.id or false }
|
||||
if #groups == 0 then
|
||||
v.title, v.sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
|
||||
elseif not grp then
|
||||
v.title, v.sub = "No room here", "The drills on it take all it has"
|
||||
elseif not ok then
|
||||
v.title, v.sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
|
||||
else
|
||||
local t = grp.tool
|
||||
local reach = reach_short(st, t.reach)
|
||||
v.title = "+ Deploy " .. M.short_name(t)
|
||||
v.sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
|
||||
if here < amount - EPS then
|
||||
v.sub, v.color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
|
||||
end
|
||||
end
|
||||
return v
|
||||
end
|
||||
|
||||
-- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a
|
||||
-- cycle gets here, or why it can't (no idle drills, no room). v: deploy_view. It sends
|
||||
-- the group's first idle copy at the click (a kept slot outlives the drill it was built
|
||||
-- with); its test id is the well's: act:stake?target=<well>.
|
||||
local function deploy_slot(g, well, v, mk, H)
|
||||
return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
|
||||
part(g, "mo:deploy:" .. well.id, blocks.sig(w, v, mk), p0, x0, y0, function(p)
|
||||
local x, y = 0, 0
|
||||
-- (off: a plain line and no fill, so it reads as greyed beside a live slot)
|
||||
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not v.ok,
|
||||
edge = not v.ok and "line" or nil, fill = not v.ok and { "cyan", 0 } or nil })
|
||||
local tree = col({ width = w, height = SLOT_H, padding = { 0, 10 }, justify = "center", align = "center", gap = 2 }, {
|
||||
fit(b, 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
|
||||
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
|
||||
|
||||
-- What a drill's slot shows besides the drill d: dm = { name, due, reach } (drill_models).
|
||||
-- A drill's slot (SLOT_H tall; slides in when it was just deployed): its short name and
|
||||
-- time left over its timer bar and what it gets; done (ready, or its end passed on our
|
||||
-- clock): gold, "+X ready", Claim (scope: claim-well or claim-wide) and, once unlocked,
|
||||
@@ -345,105 +399,132 @@ end
|
||||
-- the plain action ids). wait: the group's next drill to finish; where its Claim and
|
||||
-- Redeploy will show is registered as "wait:claim?..." (like H.claim_wait's waiting
|
||||
-- button; not focusable), for the test driver.
|
||||
local function drill_slot(g, st, d, scope, can_redeploy, n, wait, H, enters)
|
||||
local tool = find(st.tools, d.toolId)
|
||||
local due = d.ready or (d["end"] or math.huge) <= H.now(g)
|
||||
local reach = reach_short(st, d.reach)
|
||||
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
|
||||
local slot = ui.rect(p, x, y, w, SLOT_H, "none")
|
||||
local body = ui.rect(slot, 0, 0, w, SLOT_H, due and "ready" or "cyan", due and 0.08 or 0.04)
|
||||
ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil)
|
||||
local iw = w - 18
|
||||
local right
|
||||
local gets = d.here or d.take or d.amount or 0
|
||||
if due then
|
||||
-- (one that gets nothing is claimed all the same: it frees the drill)
|
||||
right = fg.label(body, gets > 0 and ("+" .. ui.money(gets) .. " ready") or "Nothing left",
|
||||
{ font = "sans_semi", px = 12, color = gets > 0 and "ready" or "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 })
|
||||
else
|
||||
right = fg.label(body, ui.dur(d["end"] - H.now(g)), M12, 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" })
|
||||
local function drill_slot(g, st, d, dm, scope, can_redeploy, n, wait, mk, H, enters)
|
||||
local due = dm.due
|
||||
local reach = dm.reach or nil
|
||||
return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
|
||||
-- (a part of its own: a card that changes keeps the slots that didn't)
|
||||
part(g, "mo:slot:" .. d.toolId, blocks.sig(w, d, dm, scope, can_redeploy, n, wait, mk), p0, x0, y0, function(p)
|
||||
local x, y = 0, 0
|
||||
local slot = ui.rect(p, x, y, w, SLOT_H, "none")
|
||||
local body = ui.rect(slot, 0, 0, w, SLOT_H, due and "ready" or "cyan", due and 0.08 or 0.04)
|
||||
ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil)
|
||||
local iw = w - 18
|
||||
local right
|
||||
local gets = d.here or d.take or d.amount or 0
|
||||
if due then
|
||||
-- (one that gets nothing is claimed all the same: it frees the drill)
|
||||
right = fg.label(body, gets > 0 and ("+" .. ui.money(gets) .. " ready") or "Nothing left",
|
||||
{ font = "sans_semi", px = 12, color = gets > 0 and "ready" or "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 })
|
||||
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, 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
|
||||
right = fg.label(body, ui.dur(d["end"] - H.now(g)), M12, 16, { shrink = 0 })
|
||||
right.done = function(t)
|
||||
H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
-- The slot of the idle drills that last mined the well (well.redrill): how many, and
|
||||
-- Redrill (sends them out again) once unlocked and the well has supply.
|
||||
local function idle_slot(g, st, well, H)
|
||||
-- The drill of a list that finishes next (not done yet), or nil.
|
||||
local function next_drill(g, drills)
|
||||
local now, pick = g.h.now(g), nil
|
||||
for _, d in ipairs(drills or {}) do
|
||||
if not d.ready and d["end"] and d["end"] > now and (not pick or d["end"] < pick["end"]) then
|
||||
pick = d
|
||||
end
|
||||
end
|
||||
return pick
|
||||
end
|
||||
|
||||
-- The drills of a group (a well's, or the multi-well ones) as their slots show them:
|
||||
-- { name, due, reach, n (its place among the ready ones, 0: not ready), wait }.
|
||||
local function drill_models(g, st, drills, H)
|
||||
local now, next_d = H.now(g), next_drill(g, drills)
|
||||
local out, n = {}, 0
|
||||
for i, d in ipairs(drills or {}) do
|
||||
local due = d.ready or (d["end"] or math.huge) <= now
|
||||
n = n + (due and 1 or 0)
|
||||
out[i] = { name = M.short_name(find(st.tools, d.toolId)), due = due and true or false,
|
||||
reach = reach_short(st, d.reach) or false, n = due and n or 0, wait = d == next_d }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- What the idle slot of a well shows (well.redrill > 0).
|
||||
local function idle_view(st, well)
|
||||
local n = well.redrill or 0
|
||||
local names = {}
|
||||
for _, t in ipairs(st.tools or {}) do
|
||||
@@ -454,21 +535,27 @@ local function idle_slot(g, st, well, H)
|
||||
local who = #names > 0 and (table.concat(names, ", ") .. (n > #names and (" +" .. (n - #names)) or "")) or ui.plural(n, "drill")
|
||||
-- (no Redrill while the drills on the well take all it has: the server refuses it)
|
||||
local full = well.supply > 0 and (well.remain or 1) <= 1e-9
|
||||
local can = st.unlocked.redrill and well.supply > 0 and not full
|
||||
return { n = n, who = who, full = full, can = (st.unlocked.redrill and well.supply > 0 and not full) and true or false }
|
||||
end
|
||||
|
||||
-- The slot of the idle drills that last mined the well (well.redrill): how many, and
|
||||
-- Redrill (sends them out again) once unlocked and the well has supply. v: idle_view.
|
||||
local function idle_slot(g, well, v, H)
|
||||
local n = v.n
|
||||
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
|
||||
local body = ui.rect(p, x, y, w, SLOT_H, "cyan", 0.02)
|
||||
ui.outline(body, 0, 0, w, SLOT_H, "line_hi")
|
||||
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
|
||||
row({ height = 16, align = "center" }, {
|
||||
fit(body, who, { font = "sans_semi", px = 13, color = "text_2" }), fg.fill(), space(6),
|
||||
fit(body, v.who, { font = "sans_semi", px = 13, color = "text_2" }), fg.fill(), space(6),
|
||||
fg.label(body, "Idle", { font = "sans_semi", px = 12, color = "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 }),
|
||||
}),
|
||||
can and fg.button(body, "Redrill" .. (n > 1 and (" " .. n) or ""), { small = true, variant = "redrill", h = SLOT_BTN_H }, function(b, bn)
|
||||
v.can and fg.button(body, "Redrill" .. (n > 1 and (" " .. n) or ""), { small = true, variant = "redrill", h = SLOT_BTN_H }, function(b, bn)
|
||||
H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
|
||||
tooltip.add(b, "Send the drills that last mined this well out again")
|
||||
end, { height = SLOT_BTN_H }) or row({ height = SLOT_BTN_H, align = "center" }, {
|
||||
fit(body, well.supply <= 0 and "Last mined here · the well is empty"
|
||||
or (full and "Last mined here · the drills on it take the rest" or "Last mined here"), S11),
|
||||
or (v.full and "Last mined here · the drills on it take the rest" or "Last mined here"), S11),
|
||||
}),
|
||||
})
|
||||
fg.build(flex.solve(tree, 0, 0), body)
|
||||
@@ -488,40 +575,53 @@ local function well_note(c, well)
|
||||
end
|
||||
end
|
||||
|
||||
-- The drill of a list that finishes next (not done yet), or nil.
|
||||
local function next_drill(g, drills)
|
||||
local now, pick = g.h.now(g), nil
|
||||
for _, d in ipairs(drills or {}) do
|
||||
if not d.ready and d["end"] and d["end"] > now and (not pick or d["end"] < pick["end"]) then
|
||||
pick = d
|
||||
end
|
||||
end
|
||||
return pick
|
||||
end
|
||||
|
||||
-- One well's card (a grid cell; its node selects the well when clicked).
|
||||
local function well_card(g, c, st, moon, well, max, groups, H, enters)
|
||||
-- What a well's card shows (its block's signature with its rect): the well, its deploy
|
||||
-- slot, its drills' slots, its idle slot. slots: how many (card_h).
|
||||
local function card_model(g, st, well, max, groups, H)
|
||||
local dry = well.supply <= 0
|
||||
local selected = well.id == g.view.well
|
||||
local scope = { ["claim-well"] = well.id }
|
||||
local slots = {}
|
||||
if not dry then
|
||||
slots[#slots + 1] = deploy_slot(g, st, well, groups, H)
|
||||
end
|
||||
local n, next_d = 0, next_drill(g, well.drills)
|
||||
-- Tapped out once the ready drills are claimed (nothing left, or what is left is the
|
||||
-- other drills' on it, the server's `remain`): Claim only, no Redeploy.
|
||||
local tapped = well.supply - ready.amount(g, well, well.cap) <= 1e-9 or (well.remain or 1) <= 1e-9
|
||||
for _, d in ipairs(well.drills or {}) do
|
||||
local due = d.ready or (d["end"] or math.huge) <= H.now(g)
|
||||
n = n + (due and 1 or 0)
|
||||
slots[#slots + 1] = drill_slot(g, st, d, scope, not dry and not tapped and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters)
|
||||
local m = {
|
||||
well = well, max = max, dry = dry, selected = well.id == g.view.well,
|
||||
redeploy = (not dry and not tapped and st.unlocked.redeploy) and true or false,
|
||||
deploy = not dry and deploy_view(g, st, well, groups) or false,
|
||||
drills = drill_models(g, st, well.drills, H),
|
||||
idle = (well.redrill or 0) > 0 and idle_view(st, well) or false,
|
||||
}
|
||||
m.slots = (dry and 0 or 1) + #m.drills + (m.idle and 1 or 0)
|
||||
local scope = "claim?claim-well=" .. well.id
|
||||
m.marks = marks(g, { scope, scope .. "&redeploy=1", "stake?target=" .. well.id, "redrill?redrill-well=" .. well.id })
|
||||
return m
|
||||
end
|
||||
|
||||
-- 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
|
||||
if (well.redrill or 0) > 0 then
|
||||
slots[#slots + 1] = idle_slot(g, st, well, H)
|
||||
for i, d in ipairs(well.drills or {}) do
|
||||
local dm = m.drills[i]
|
||||
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
|
||||
end
|
||||
if m.idle then
|
||||
slots[#slots + 1] = idle_slot(g, well, m.idle, H)
|
||||
end
|
||||
local sup = fit(c, ui.money(well.supply), { font = "mono", px = 13, color = dry and "muted" or "text" }, { shrink = 0 })
|
||||
sup.done = function(node) H.tick_num(g, node, "supply:" .. well.id, well.supply) end
|
||||
local list = col({ gap = SLOT_GAP }, slots)
|
||||
local kids = {
|
||||
row({ height = 18, align = "center" }, {
|
||||
made({ width = 8, height = 8, shrink = 0 }, function(q, x, y) ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1) end),
|
||||
@@ -529,48 +629,64 @@ local function well_card(g, c, st, moon, well, max, groups, H, enters)
|
||||
fg.fill(), space(6), sup,
|
||||
}),
|
||||
space(0, 7),
|
||||
made({ height = 6, shrink = 0 }, function(q, x, y, w)
|
||||
made({ height = 6, shrink = 0 }, function(q, x, y, bw)
|
||||
if dry then
|
||||
ui.rect(q, x, y, w, 6, "line")
|
||||
ui.rect(q, x, y, bw, 6, "line")
|
||||
else
|
||||
M.supply_bar(q, x, y, w, well, max)
|
||||
M.supply_bar(q, x, y, bw, well, m.max)
|
||||
end
|
||||
end),
|
||||
space(0, 4),
|
||||
fg.reserve({ height = 16 }, well_note(c, well)),
|
||||
space(0, 8),
|
||||
col({ gap = SLOT_GAP }, slots),
|
||||
list,
|
||||
}
|
||||
return col({ padding = CARD_PAD }, kids, { make = function(parent, x, y, w, h)
|
||||
local slot = ui.rect(parent, x, y, w, h, "none")
|
||||
local card = ui.rect(slot, 0, 0, w, h, "cyan", 0)
|
||||
row_c.bind(g, card, w, h, { selected = selected, id = "well:" .. well.id, label = well.resource,
|
||||
local tree = col({ padding = CARD_PAD }, kids, { make = function(parent, x, y, cw, ch)
|
||||
local slot = ui.rect(parent, x, y, cw, ch, "none")
|
||||
local card = ui.rect(slot, 0, 0, cw, ch, "cyan", 0)
|
||||
row_c.bind(g, card, cw, ch, { selected = m.selected, id = "well:" .. well.id, label = well.resource,
|
||||
on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
|
||||
ui.outline(card, 0, 0, w, h, selected and "cyan" or "line_hi", selected and 0.9 or nil)
|
||||
g.well_nodes[well.id] = card
|
||||
ui.outline(card, 0, 0, cw, ch, m.selected and "cyan" or "line_hi", m.selected and 0.9 or nil)
|
||||
blocks.put(g, "well_nodes", well.id, card)
|
||||
return card
|
||||
end })
|
||||
fg.build(flex.solve(tree, 0, 0, w, h), c)
|
||||
if math.abs(list.y + list.h + CARD_PAD - card_h(m.slots)) > 0.5 then
|
||||
print("ERROR:MM: moons: a well card's height is " .. (list.y + list.h + CARD_PAD) .. ", card_h says " .. card_h(m.slots))
|
||||
end
|
||||
for _, f in ipairs(enters) do
|
||||
f()
|
||||
end
|
||||
end
|
||||
|
||||
-- The multi-well drills (moon.wide: reach > 1, claimed together with claim-wide): a card
|
||||
-- across the grid, its slots in the cards' columns.
|
||||
local function wide_card(g, c, st, moon, inner, H, enters)
|
||||
-- The multi-well drills (moon.wide: reach > 1, claimed together with claim-wide): what its
|
||||
-- card shows.
|
||||
local function wide_model(g, st, moon, H)
|
||||
local wide = moon.wide
|
||||
local scope = { ["claim-wide"] = moon.id }
|
||||
local slots, n, next_d = {}, 0, next_drill(g, wide.drills)
|
||||
-- (the moon tapped out once these are claimed: no Redeploy)
|
||||
local left = 0
|
||||
for _, w in ipairs(moon.wells or {}) do
|
||||
left = left + w.supply
|
||||
end
|
||||
local tapped = left - ready.amount(g, wide, wide.cap) <= 1e-9
|
||||
for _, d in ipairs(wide.drills or {}) do
|
||||
local due = d.ready or (d["end"] or math.huge) <= H.now(g)
|
||||
n = n + (due and 1 or 0)
|
||||
slots[#slots + 1] = drill_slot(g, st, d, scope, not tapped and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters)
|
||||
local scope = "claim?claim-wide=" .. moon.id
|
||||
return { wide = wide, redeploy = (not tapped and st.unlocked.redeploy) and true or false,
|
||||
drills = drill_models(g, st, wide.drills, H), marks = marks(g, { scope, scope .. "&redeploy=1" }) }
|
||||
end
|
||||
|
||||
-- Its card across the grid (inner wide, at the origin of c), its slots in the cards'
|
||||
-- columns. Returns its height.
|
||||
local function wide_card(g, c, st, moon, m, inner, H)
|
||||
local wide = m.wide
|
||||
local scope = { ["claim-wide"] = moon.id }
|
||||
local enters = {}
|
||||
local slots = {}
|
||||
for i, d in ipairs(wide.drills or {}) do
|
||||
local dm = m.drills[i]
|
||||
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
|
||||
end
|
||||
local sw = inner - CARD_PAD * 2
|
||||
return col({ width = inner, padding = CARD_PAD }, {
|
||||
local tree = col({ width = inner, padding = CARD_PAD }, {
|
||||
row({ height = 18, align = "center" }, {
|
||||
fg.label(c, "Multi-well", { font = "sans_semi", px = 15, color = "text" }, 18), space(10),
|
||||
fit(c, ui.money(wide.cap or 0) .. " left on the moon", S12), fg.fill(),
|
||||
@@ -582,28 +698,16 @@ local function wide_card(g, c, st, moon, inner, H, enters)
|
||||
ui.outline(card, 0, 0, w, h, "line_hi")
|
||||
return card
|
||||
end })
|
||||
fg.build(flex.solve(tree, 0, 0), c)
|
||||
for _, f in ipairs(enters) do
|
||||
f()
|
||||
end
|
||||
return tree.h
|
||||
end
|
||||
|
||||
-- The tray of idle drills: a tile per group (picture, short name, ×count, what a cycle
|
||||
-- gets); a tile toggles the pick the deploy slots send.
|
||||
local function tray(g, c, st, groups, inner, H)
|
||||
local head = row({ height = 16, align = "center" }, {
|
||||
fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0),
|
||||
#groups > 0 and fit(c, "Click one to deploy it to the selected well", "small") or nil,
|
||||
})
|
||||
if #groups == 0 then
|
||||
return col({ width = inner }, {
|
||||
head, space(0, 8),
|
||||
row({ height = 16, align = "center" }, {
|
||||
fg.label(c, "No idle drills.", "small", 16),
|
||||
st.unlocked.store and space(5) or nil,
|
||||
st.unlocked.store and made({ width = 160, height = 16, shrink = 0 }, function(p, x, y)
|
||||
H.text_link(g, p, "Buy one in the store", { font = "sans", px = 13 }, x, y + 8, { section = "store", moon = g.view.moon })
|
||||
end) or nil,
|
||||
}),
|
||||
})
|
||||
end
|
||||
-- The well a tile deploys to: the selected one, else the first it has room on.
|
||||
-- What the tray of idle drills shows: a tile per group (the well it deploys to: the
|
||||
-- selected one, else the first it has room on).
|
||||
local function tray_model(g, st, groups)
|
||||
local moon = st.moon
|
||||
local function target(t)
|
||||
local wells = moon and moon.wells or {}
|
||||
@@ -618,40 +722,78 @@ local function tray(g, c, st, groups, inner, H)
|
||||
end
|
||||
end
|
||||
end
|
||||
local tiles = {}
|
||||
for _, grp in ipairs(groups) do
|
||||
local tiles, keys = {}, {}
|
||||
for i, grp in ipairs(groups) do
|
||||
local t = grp.tool
|
||||
local well = target(t)
|
||||
local here = well and gets_here(st, well, t) or 0
|
||||
local ok = well ~= nil and well.supply > 0 and here > EPS
|
||||
local picked = false
|
||||
tiles[#tiles + 1] = made({ height = TILE_H }, function(p, x, y, w)
|
||||
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = TILE_H, disabled = not ok })
|
||||
local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg")
|
||||
if t.image then
|
||||
images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), t.image)
|
||||
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)
|
||||
tiles[i] = { key = grp.key, name = M.short_name(t), count = grp.count, tool = t.id, tname = t.name or false, image = t.image or false,
|
||||
gets = "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach_short(st, t.reach) and (" · " .. reach_short(st, t.reach)) or ""),
|
||||
well = well and well.id or false, resource = well and well.resource or false,
|
||||
ok = (well ~= nil and well.supply > 0 and here > EPS) and true or false }
|
||||
if well then
|
||||
keys[#keys + 1] = "stake?target=" .. well.id .. "&tool=" .. t.id
|
||||
end
|
||||
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
|
||||
|
||||
|
||||
@@ -683,13 +825,10 @@ local function rating_chip(c, r)
|
||||
end)
|
||||
end
|
||||
|
||||
-- The moon: heading (id, name, class and rating, description; Claim at the right), the
|
||||
-- well cards, the multi-well card, the tray of idle drills, and selling the moon (once
|
||||
-- the store is open). min_h tall at least. Returns its bottom.
|
||||
local function moon_view(g, c, px, py, inner, min_h, H)
|
||||
local st = g.state
|
||||
local moon = st.moon
|
||||
|
||||
-- The heading (id, name, class and rating, description; Claim at the right), inner wide
|
||||
-- at the origin of c, 80 tall at least. Returns its height.
|
||||
local function heading(g, c, moon, n_ready, inner, H)
|
||||
-- The class line ("<type> class"), but not for moons named after their type (from
|
||||
-- before moon names), like the web.
|
||||
local has_class = moon.kind and moon.kind ~= "" and moon.kind ~= moon.name
|
||||
@@ -713,7 +852,6 @@ local function moon_view(g, c, px, py, inner, min_h, H)
|
||||
head[#head + 1] = space(0, 4)
|
||||
end
|
||||
-- Claim, at the top right (out of the flow: the heading's text keeps clear of it).
|
||||
local n_ready = ready.drills(g, moon.id)
|
||||
if moon.ready > 0 then
|
||||
head[#head + 1] = made({ position = "absolute", top = 26, right = 0 }, function(p, x, y)
|
||||
local b, nodes, bw = ui.button(p, "Claim " .. ui.plural(n_ready, "drill"), x, y, { primary = true })
|
||||
@@ -727,9 +865,31 @@ local function moon_view(g, c, px, py, inner, min_h, H)
|
||||
done = n_ready > 0 and ("Claim " .. ui.plural(n_ready, "drill")) or nil })
|
||||
end)
|
||||
end
|
||||
local tree = col({ width = inner, min_height = 80, align = "start" }, head)
|
||||
fg.build(flex.solve(tree, 0, 0), c)
|
||||
return tree.h
|
||||
end
|
||||
|
||||
-- The cards: a well each (as many columns as CARD_MIN allows), the multi-well card
|
||||
-- across them, then the idle drills.
|
||||
-- The moon: heading, the well cards, the multi-well card, the tray of idle drills (selling
|
||||
-- the moon is in the aside: aside.lua), each a kept part, top to bottom from (px, py) of
|
||||
-- c. min_h tall at least. Returns its bottom.
|
||||
local function moon_view(g, c, px, py, inner, min_h, H)
|
||||
local st = g.state
|
||||
local moon = st.moon
|
||||
local now = H.now(g)
|
||||
local y = py
|
||||
|
||||
local n_ready = ready.drills(g, moon.id)
|
||||
local claim = "claim?claim-moon=" .. moon.id
|
||||
y = y + part(g, "mo:head", blocks.sig(inner, moon.id, moon.name, moon.kind or false, moon.grade or false,
|
||||
moon.rating or false, moon.description or false, moon.ready, moon.nextEnd or false,
|
||||
(moon.nextEnd or math.huge) <= now, n_ready, marks(g, { claim })), c, px, y, function(root)
|
||||
return heading(g, root, moon, n_ready, inner, H)
|
||||
end)
|
||||
y = y + 14
|
||||
|
||||
-- The cards: a well each (as many columns as CARD_MIN allows; a row as tall as its
|
||||
-- tallest card), the multi-well card across them, then the idle drills.
|
||||
local max = M.supply_max(st, moon)
|
||||
local groups = M.idle_groups(st)
|
||||
local pick_ok = false
|
||||
@@ -739,30 +899,39 @@ local function moon_view(g, c, px, py, inner, min_h, H)
|
||||
if not pick_ok then
|
||||
g.deploy_pick = nil -- (used up)
|
||||
end
|
||||
local enters = {}
|
||||
local cards = {}
|
||||
for _, well in ipairs(moon.wells) do
|
||||
cards[#cards + 1] = well_card(g, c, st, moon, well, max, groups, H, enters)
|
||||
local cols, cw = flex.columns(inner, CARD_MIN, CARD_GAP)
|
||||
local wells = moon.wells or {}
|
||||
local models = {}
|
||||
for i, well in ipairs(wells) do
|
||||
models[i] = card_model(g, st, well, max, groups, H)
|
||||
end
|
||||
for first = 1, #wells, cols do
|
||||
local last = math.min(#wells, first + cols - 1)
|
||||
local rh = 0
|
||||
for i = first, last do
|
||||
rh = math.max(rh, card_h(models[i].slots))
|
||||
end
|
||||
for i = first, last do
|
||||
local m = models[i]
|
||||
part(g, "mo:well:" .. wells[i].id, blocks.sig(cw, rh, m), c, px + (i - first) * (cw + CARD_GAP), y, function(root)
|
||||
well_card(g, root, st, moon, m, cw, rh, H)
|
||||
end)
|
||||
end
|
||||
y = y + rh + (last < #wells and CARD_GAP or 0)
|
||||
end
|
||||
local kids = {
|
||||
col({ min_height = 80, align = "start" }, head),
|
||||
space(0, 14),
|
||||
flex.grid(inner, CARD_MIN, CARD_GAP, cards),
|
||||
}
|
||||
if moon.wide and #(moon.wide.drills or {}) > 0 then
|
||||
kids[#kids + 1] = space(0, CARD_GAP)
|
||||
kids[#kids + 1] = wide_card(g, c, st, moon, inner, H, enters)
|
||||
y = y + CARD_GAP
|
||||
local m = wide_model(g, st, moon, H)
|
||||
y = y + part(g, "mo:wide", blocks.sig(inner, moon.id, m), c, px, y, function(root)
|
||||
return wide_card(g, root, st, moon, m, inner, H)
|
||||
end)
|
||||
end
|
||||
kids[#kids + 1] = space(0, 20)
|
||||
kids[#kids + 1] = tray(g, c, st, groups, inner, H)
|
||||
-- (selling the moon, a destructive action, is in the aside: aside.lua)
|
||||
|
||||
local page = col({ width = inner, min_height = min_h }, kids)
|
||||
fg.build(flex.solve(page, px, py), c)
|
||||
for _, f in ipairs(enters) do
|
||||
f()
|
||||
end
|
||||
return page.y + page.h
|
||||
y = y + 20
|
||||
local tm = tray_model(g, st, groups)
|
||||
y = y + part(g, "mo:tray", blocks.sig(inner, tm), c, px, y, function(root)
|
||||
return tray(g, root, tm, inner, H)
|
||||
end)
|
||||
return math.max(y, py + min_h)
|
||||
end
|
||||
|
||||
|
||||
|
||||
@@ -147,6 +147,25 @@ function M.build_teaser(g, root, px, py, inner)
|
||||
ui.rect(p, x + 16, y, w - 32, 1, "edge")
|
||||
return nil
|
||||
end } or nil)
|
||||
-- Moon packs (once the store is open): several at once, cheaper and better, a button
|
||||
-- each under the grade's facts.
|
||||
if #(e.packs or {}) > 0 then
|
||||
local packs = {}
|
||||
for _, pk in ipairs(e.packs) do
|
||||
local label = pk.count .. " moons · " .. theme.currency .. ui.money(pk.price)
|
||||
packs[#packs + 1] = fg.button(root, label, { small = true, disabled = not pk.canAfford }, function(b, nodes)
|
||||
local save = math.floor(pk.discount * 100 + 0.5)
|
||||
tooltip.add(b, pk.canAfford and (pk.count .. " " .. e.grade .. " moons for " .. theme.currency .. ui.money(pk.price)
|
||||
.. " (" .. save .. "% off), each a little richer than a single one")
|
||||
or ("Need " .. theme.currency .. ui.money(pk.price - st.credits) .. " more"))
|
||||
if pk.canAfford then
|
||||
g.h.action(g, b, nodes, "buy-moon", { grade = e.grade, count = pk.count, include = "new-moon" },
|
||||
function() g.nm_grade = nil end, nil, "newmoon:pack:" .. e.grade .. ":" .. pk.count)
|
||||
end
|
||||
end)
|
||||
end
|
||||
rows[#rows + 1] = row({ height = 40, align = "start", padding = { 0, 16, 10, 56 }, gap = 8 }, packs)
|
||||
end
|
||||
end
|
||||
local picker = panel_box({ width = inner, padding = { 14, 0, 6, 0 } }, {
|
||||
row({ padding = { 0, 16, 0, 16 } }, { fg.label(root, "Moon grades", "cap", 12) }),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""The settings menu's rows take real mouse clicks (xdotool), also where the menu lies over
|
||||
the moon viewport (its drag used to take the press)."""
|
||||
|
||||
|
||||
def run(game, args):
|
||||
game.idle()
|
||||
checks = [
|
||||
("mute", "tostring(require('modules.sfx').muted())"),
|
||||
("theme", "tostring(require('modules.theme').light)"),
|
||||
("motion", "tostring(require('modules.anim').reduced)"),
|
||||
("dialog:fullscreen", "tostring(mmwindow.is_fullscreen())"),
|
||||
("menu:save-code", "tostring(g.dialog:is_open())"),
|
||||
]
|
||||
for id_, expr in checks:
|
||||
game.os_click("settings")
|
||||
game.wait_for("g.dialog:is_open()", timeout=5)
|
||||
game.sleep(0.8)
|
||||
before = game.eval(expr)
|
||||
game.os_click(id_)
|
||||
game.sleep(1.2)
|
||||
after = game.eval(expr)
|
||||
print(" %-18s %s -> %s dialog open: %s" % (id_, before, after, game.eval("tostring(g.dialog:is_open())")))
|
||||
game.shot("after-" + id_.replace(":", "_"), settle=0.2)
|
||||
game.key("key_esc")
|
||||
game.sleep(0.6)
|
||||
@@ -17,6 +17,10 @@ if [ -z "${MM_IN_XVFB:-}" ]; then
|
||||
fi
|
||||
|
||||
export LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-1}
|
||||
# The game's saved files (player ids, settings) go beside the screenshot, never into the
|
||||
# player's own (~/.local/share/moonminers): mm.new_player=1 would clear their saved id.
|
||||
export XDG_DATA_HOME=${MM_SHOT_DATA:-$(dirname "$out")/data}
|
||||
mkdir -p "$XDG_DATA_HOME"
|
||||
cd "$(dirname "$bin")"
|
||||
log=$(dirname "$out")/shot.log
|
||||
TIMEOUT=${TIMEOUT:-120}
|
||||
|
||||
@@ -117,7 +117,14 @@ up() {
|
||||
local s=$WT/server ms=$MAIN/server
|
||||
[[ -e $s/web/static/css/app.css || ! -e $ms/web/static/css/app.css ]] || { mkdir -p "$s/web/static/css"; cp "$ms/web/static/css/app.css" "$s/web/static/css/"; }
|
||||
[[ -e $s/web/static/app || ! -d $ms/web/static/app ]] || cp -a "$ms/web/static/app" "$s/web/static/app"
|
||||
# The copied .env names this worktree's own database, so anything run here (migrate,
|
||||
# tests, the server) reaches mm_<task>, never the main checkout's.
|
||||
[[ -e $WT/.env ]] || cp "$MAIN/.env" "$WT/.env"
|
||||
if grep -q '^DB_DATABASE=' "$WT/.env"; then
|
||||
sed -i "s/^DB_DATABASE=.*/DB_DATABASE=$DB/" "$WT/.env"
|
||||
else
|
||||
echo "DB_DATABASE=$DB" >>"$WT/.env"
|
||||
fi
|
||||
echo "$DB" >"$ST/db"
|
||||
if [[ -z $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then
|
||||
admin_sql "create database $DB"
|
||||
|
||||
+12
-5
@@ -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
|
||||
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they can't be sold. The Past tab loads them 10 at a
|
||||
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
|
||||
`loadView` (state, the past page, the resolved view).
|
||||
`loadView` (state, the past page, the resolved view). `loadState` leaves moon
|
||||
descriptions out (`apiPageFor` loads the viewed moon's, `loadMoonDescription`);
|
||||
`loadConfig` (resources, store items) is cached per config snapshot.
|
||||
- `views.go`: the `View` (section, moon, well, tab; the API's view params `s=&moon=&well=`),
|
||||
the `Page` view model, formatting helpers, the admin templates' functions.
|
||||
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
|
||||
@@ -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
|
||||
`market_pressure`; the factor eases from 1 towards `floor`, and a sale is priced at its
|
||||
average over the pressure it adds). Personal modifiers never change the quote, cards,
|
||||
ticker or 24h history (recomputed, no table); the sell ticket shows them as lines ("+8%
|
||||
ticker or 24h history (recomputed, no table; each item's 24h series is cached for the
|
||||
current tick and config snapshot, `marketSeriesAt`, shared by every request; live events
|
||||
are applied on top per request); the sell ticket shows them as lines ("+8%
|
||||
for you", "−4% from your recent selling"). View model for the Market section (ticker,
|
||||
watchlist, chart, sell ticket; `pick=` selects a quote).
|
||||
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
|
||||
@@ -98,7 +102,7 @@ every command below from this directory (`server/`).
|
||||
Bonuses are fixed when the action runs (a stake keeps its duration and amount).
|
||||
- `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`,
|
||||
`tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds
|
||||
flat, with a price and, higher up, refined `parts`. Levels are per tool
|
||||
flat, with a price and optional refined `parts` (none in game.json now: credits only). Levels are per tool
|
||||
(`inventory_item.yield_level`, `cycle_level`; copies with the same levels are
|
||||
interchangeable, `pickTool`); action `upgrade` (`track=yield|cycle`), counted from the next
|
||||
stake. Reach is the item's and never changes. Also the view models (`TrackRow` with its
|
||||
@@ -143,7 +147,7 @@ every command below from this directory (`server/`).
|
||||
via `eventMultiplier` in game.go, `announcement`; both in the API's `banner`).
|
||||
- `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in
|
||||
use; the tools show their original images.
|
||||
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–020):
|
||||
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–022):
|
||||
changes for existing ones, applied on every `migrate`, so each must be idempotent.
|
||||
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
|
||||
- `art/`: sources of the store and advisor images (Blender scripts, prompts);
|
||||
@@ -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.
|
||||
Other tests: `go test ./...` (game rules on in-memory states, no database);
|
||||
`wells_test.go` (`go test -run Wells -v`: well roll distribution, value by well count,
|
||||
resale odds, sim seeds).
|
||||
resale odds, sim seeds). `claim_db_test.go` needs a database of its own
|
||||
(`MM_TEST_DATABASE=mm_<task>`, from `scripts/wt`; skipped without it): `TestClaimGolden`
|
||||
compares claim-all's results with `testdata/claim_golden.txt` (`MM_CLAIM_GOLDEN_WRITE=1`
|
||||
rewrites it, only for a deliberate rule change), `TestClaimAllPerf -v` times it.
|
||||
|
||||
## Deployment
|
||||
- Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`;
|
||||
|
||||
@@ -39,7 +39,8 @@ game is moon and drill prices with wells scaled alike.
|
||||
|
||||
Attachments and the T1–T5 tier ladder are gone. Each drill has two tracks of its own
|
||||
(config `storeItems[].tracks`), 4–5 levels each, every level a flat value: yield (claim per
|
||||
cycle) and cycle (seconds, to a tenth). Reach never changes. Levels 3+ need refined parts.
|
||||
cycle) and cycle (seconds, to a tenth). Reach never changes. Levels cost credits only (the refined parts they once took gated the
|
||||
early game behind a refinery the player doesn't have yet; the code still takes `parts`).
|
||||
Prices: level k of yield costs 0.97 × drill price × (Δclaim / base claim) × (base cycle /
|
||||
cycle at level k−1), cycle level k costs 0.97 × price × (claim at level k−1 / base) × Δ(base
|
||||
cycle / cycle), × 0.95^(k−1), minus the parts' value, two significant digits, never below
|
||||
|
||||
+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`
|
||||
(comma-separated player ids that are admins in the game API: `state.admin`, `admin-grant`,
|
||||
saving moon settings), `MM_DEV=1` (reload templates and static files from disk; no admin
|
||||
login, and every player is an admin), `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
|
||||
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
|
||||
```
|
||||
|
||||
3. Run the server (`MM_DEV=1`: no admin login) and open the admin at
|
||||
http://localhost:3000/utils, or point the Defold client at it:
|
||||
3. Run the server (`MM_DEV=1`: no admin login). The admin is at
|
||||
http://localhost:3000/utils; the game at http://localhost:3000 once the client's
|
||||
`make web` has built it (then restart the server), or run the desktop client against it:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
@@ -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
|
||||
(`users.refinery_batch_level`), 020 drill upgrade tracks (`inventory_item.yield_level`,
|
||||
`cycle_level`; old tiers became cycle levels, attachments were refunded and dropped,
|
||||
`upgrade_item` dropped, stake cycles in tenths of a second).
|
||||
`upgrade_item` dropped, stake cycles in tenths of a second), 022 indexes for the per-request
|
||||
state reads (moons, wells, tools and stakes by player, stakes by well and by tool).
|
||||
|
||||
## Game config
|
||||
|
||||
|
||||
@@ -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.
|
||||
func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, error) {
|
||||
if v.Section == "moons" {
|
||||
// The moon view is the only one that shows a description (loadState leaves them out).
|
||||
if err := loadMoonDescription(ctx, s.db, state, v.Moon); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
page := state.page(v)
|
||||
if err := s.loadRanking(ctx, page); err != nil {
|
||||
return nil, err
|
||||
|
||||
+14
-10
@@ -74,7 +74,7 @@ Core-loop actions and their params:
|
||||
|
||||
| action | params | notes |
|
||||
|---|---|---|
|
||||
| `buy-moon` | `grade` (optional: `D`, `C`, `B`, `A`, `S`; default `state.moonGrade`, the grade of the newest moon, else the lowest) | view moves to the new moon; an unknown grade is a rule error |
|
||||
| `buy-moon` | `grade` (optional: `D`, `C`, `B`, `A`, `S`; default `state.moonGrade`, the grade of the newest moon, else the lowest) | view moves to the new moon; an unknown grade is a rule error. `count` (optional): a moon pack's size (`newMoon.grades[].packs`: 5, 10, 20 once the store is open) buys that many at once, at the pack's price, each moon at least the pack's floor; the view and `reveal` show the best of them |
|
||||
| `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used. Refused ("Nothing left to mine on this well") when the drills already on the moon will take all the tool could get (its deploy card's `here` is 0, `blocked`) |
|
||||
| `claim` | `claim-well` or `claim-moon` or `claim-wide` (moon id), `redeploy=1` | no scope: claim all (needs `unlocked["claim-all"]`); `redeploy` needs `unlocked.redeploy`; a tool whose well is drained or has no room left (as `stake`) stays idle |
|
||||
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well, until it has no room left: each time the smallest that fills what is left alone, else the biggest (as `stake`; the rest stay idle, refused if none fits) |
|
||||
@@ -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,
|
||||
selected, stake, tracks, resale, noSale, noSaleText, bonus}],
|
||||
selected, stake, tracks, ladderKey, resale, noSale, noSaleText, bonus}],
|
||||
ladders: {<ladderKey>: {yield: [steps], cycle: [steps]}},
|
||||
drill}
|
||||
```
|
||||
|
||||
- Every tool is its own row, copies too: `count` is 1, `idle` 1 when it isn't mining, `stake`
|
||||
its drill.
|
||||
- `tracks`: the tool's two upgrade tracks, yield then cycle: `{kind, name, level, max, value,
|
||||
next, ladder}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds
|
||||
next}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds
|
||||
per cycle); values are a stake's now, with the player's bonuses and live events. Upgrade
|
||||
with `tool=<id>&track=<kind>`.
|
||||
- `next`: the next level, `null` at the top: `{level, value, label, price, parts: [{name,
|
||||
@@ -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;
|
||||
`rateFrom`/`rateTo` credits per hour now and after it, `cost` its price plus the parts at
|
||||
sell value, `paybackSecs` null if it never pays back.
|
||||
- `ladder`: every level from 0 `[{level, value, rate, price, parts: [{name, need, have}],
|
||||
current, next}]` (the other track as it is; `price` 0 for level 0).
|
||||
- `ladders`: each track's ladder, sent once for every tool with the same store item and
|
||||
levels: `ladders[ladderKey][kind]` (`ladderKey` is `"<storeItemId>:<yieldLevel>:<cycleLevel>"`)
|
||||
is every level from 0 `[{level, value, rate, price, parts: [{name, need, have}], current,
|
||||
next}]` (the other track as it is; `price` 0 for level 0).
|
||||
- `resale`: what it sells for (its price and its levels' prices × `rules.equipmentResale`);
|
||||
`noSale`: `only-tool`, `claim-first`, `mining` or `""`, `noSaleText` its text ("Claim its
|
||||
haul first").
|
||||
@@ -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,
|
||||
"next": {"level": 2, "value": 6, "label": "Twin rotors 2", "price": 320, "parts": [],
|
||||
"canAfford": true, "blocked": "", "short": 0, "blockedText": "", "shortText": "",
|
||||
"rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7},
|
||||
"ladder": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false},
|
||||
{"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"]},
|
||||
{"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…", "ladder": ["…"]}],
|
||||
"resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}],
|
||||
"rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7}},
|
||||
{"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…"}],
|
||||
"ladderKey": "1:1:2", "resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}],
|
||||
"ladders": {"1:1:2": {"yield": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false},
|
||||
{"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"],
|
||||
"cycle": ["…"]}},
|
||||
"drill": {"toolId": "13b3…", "mining": false, "resale": 980, "noSale": "", "noSaleText": "",
|
||||
"confirm": {"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok": "Sell for ₥980", "danger": true},
|
||||
"idleSame": 0, "idleBest": "", "deployTo": null}}
|
||||
|
||||
+56
-19
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
@@ -303,8 +304,11 @@ type APILiveEvent struct {
|
||||
// ------------------------------------------------------------------ equipment
|
||||
|
||||
type APIEquipment struct {
|
||||
Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped
|
||||
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
|
||||
Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped
|
||||
// The upgrade ladders by ladderKey (store item and levels: every tool with those has
|
||||
// the same), each track's by kind; a tool row names its own (ladderKey).
|
||||
Ladders map[string]map[string][]APILadderStep `json:"ladders"`
|
||||
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
|
||||
}
|
||||
|
||||
type APIToolRow struct {
|
||||
@@ -321,8 +325,9 @@ type APIToolRow struct {
|
||||
CycleSecs float64 `json:"cycleSecs"`
|
||||
Yield float64 `json:"yield"`
|
||||
Selected bool `json:"selected"`
|
||||
Stake *APIStake `json:"stake"` // the shown copy's drill
|
||||
Tracks []APITrack `json:"tracks"` // yield, then cycle
|
||||
Stake *APIStake `json:"stake"` // the shown copy's drill
|
||||
Tracks []APITrack `json:"tracks"` // yield, then cycle
|
||||
LadderKey string `json:"ladderKey"` // its tracks' ladders in equipment.ladders
|
||||
Resale float64 `json:"resale"`
|
||||
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
|
||||
NoSaleText string `json:"noSaleText"`
|
||||
@@ -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,
|
||||
// or the cycle in seconds), with bonuses and live events.
|
||||
type APITrack struct {
|
||||
Kind string `json:"kind"` // yield, cycle: upgrade with tool=<id>&track=<kind>
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
Max int `json:"max"`
|
||||
Value float64 `json:"value"`
|
||||
Next *APINextLevel `json:"next"` // null at the top level
|
||||
Ladder []APILadderStep `json:"ladder"`
|
||||
Kind string `json:"kind"` // yield, cycle: upgrade with tool=<id>&track=<kind>
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
Max int `json:"max"`
|
||||
Value float64 `json:"value"`
|
||||
Next *APINextLevel `json:"next"` // null at the top level
|
||||
}
|
||||
|
||||
type APINextLevel struct {
|
||||
@@ -399,11 +403,7 @@ func apiParts(rows []PartRow) []APIPart {
|
||||
func apiTracks(s *State, rows []TrackRow) []APITrack {
|
||||
out := []APITrack{}
|
||||
for _, r := range rows {
|
||||
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value, Ladder: []APILadderStep{}}
|
||||
for _, st := range r.Ladder {
|
||||
t.Ladder = append(t.Ladder, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
|
||||
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
|
||||
}
|
||||
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value}
|
||||
if n := r.Next; n != nil {
|
||||
next := &APINextLevel{Level: r.Level + 1, Value: r.NextValue, Label: r.Label(), Price: n.Price,
|
||||
Parts: apiParts(r.Parts), CanAfford: r.CanAfford, Short: max(0, n.Price-s.Balance),
|
||||
@@ -427,15 +427,38 @@ func apiTracks(s *State, rows []TrackRow) []APITrack {
|
||||
return out
|
||||
}
|
||||
|
||||
// ladderKey names a tool's ladders: "<store item>:<yield level>:<cycle level>".
|
||||
func ladderKey(storeItemID int, lv Levels) string {
|
||||
return fmt.Sprintf("%d:%d:%d", storeItemID, lv.Yield, lv.Cycle)
|
||||
}
|
||||
|
||||
// apiLadders are the item's ladders at the levels, by kind (the tracks it has).
|
||||
func apiLadders(s *State, item *StoreItem, lv Levels, tracks []TrackRow) map[string][]APILadderStep {
|
||||
out := map[string][]APILadderStep{}
|
||||
for _, r := range tracks {
|
||||
steps := []APILadderStep{}
|
||||
for _, st := range s.trackLadder(item, lv, r.Kind) {
|
||||
steps = append(steps, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
|
||||
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
|
||||
}
|
||||
out[r.Kind] = steps
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiEquipment(p *Page) *APIEquipment {
|
||||
s := p.S
|
||||
b := s.Bonus()
|
||||
e := &APIEquipment{Tools: []APIToolRow{}}
|
||||
e := &APIEquipment{Tools: []APIToolRow{}, Ladders: map[string]map[string][]APILadderStep{}}
|
||||
for _, row := range p.Equipment {
|
||||
key := ladderKey(row.StoreItemID, row.Levels())
|
||||
if _, ok := e.Ladders[key]; !ok {
|
||||
e.Ladders[key] = apiLadders(s, row.Item, row.Levels(), row.Tracks)
|
||||
}
|
||||
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
|
||||
Image: itemImages + row.Item.Image, YieldLevel: row.YieldLevel, CycleLevel: row.CycleLevel,
|
||||
Count: row.Count, Idle: row.Idle, Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield,
|
||||
Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), Resale: row.Item.ToolResale(row.Levels()),
|
||||
Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), LadderKey: key, Resale: row.Item.ToolResale(row.Levels()),
|
||||
NoSale: s.noSale(row), NoSaleText: noSaleText[s.noSale(row)], Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}}
|
||||
if row.Stake != nil {
|
||||
st := apiStake(*row.Stake, false)
|
||||
@@ -851,13 +874,27 @@ type APIMoonGrade struct {
|
||||
SupplyMax float64 `json:"supplyMax"`
|
||||
Default bool `json:"default"` // what buy-moon without a grade buys
|
||||
WhyText string `json:"whyText"` // why its buy button is disabled, "" if it isn't
|
||||
// Moon packs (once the store is open): buy-moon with grade=<grade>&count=<count>.
|
||||
Packs []APIMoonPack `json:"packs"`
|
||||
}
|
||||
|
||||
// APIMoonPack is count moons of a grade bought at once, for price (discount off; each moon
|
||||
// at least a little better than a single one's floor).
|
||||
type APIMoonPack struct {
|
||||
Count int `json:"count"`
|
||||
Price float64 `json:"price"`
|
||||
Discount float64 `json:"discount"`
|
||||
CanAfford bool `json:"canAfford"`
|
||||
}
|
||||
|
||||
func apiMoonGrades(gs []GradeOffer) []APIMoonGrade {
|
||||
out := []APIMoonGrade{}
|
||||
for _, g := range gs {
|
||||
a := APIMoonGrade{Grade: g.Grade, Price: g.Price, CanAfford: g.CanAfford, WellsMin: g.WellsMin, WellsMax: g.WellsMax,
|
||||
SupplyMin: g.SupplyMin, SupplyMax: g.SupplyMax, Default: g.Default}
|
||||
SupplyMin: g.SupplyMin, SupplyMax: g.SupplyMax, Default: g.Default, Packs: []APIMoonPack{}}
|
||||
for _, p := range g.Packs {
|
||||
a.Packs = append(a.Packs, APIMoonPack{Count: p.Count, Price: p.Price, Discount: p.Discount, CanAfford: p.CanAfford})
|
||||
}
|
||||
if !g.CanAfford {
|
||||
a.WhyText = "Not enough credits"
|
||||
}
|
||||
|
||||
+1
-1
@@ -204,7 +204,7 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
|
||||
}
|
||||
for _, tr := range r.Tracks {
|
||||
if n := tr.Next; n != nil && ((n.Blocked != "") != (n.BlockedText != "") || n.Label == "") ||
|
||||
len(tr.Ladder) != tr.Max+1 {
|
||||
len(e.Ladders[r.LadderKey][tr.Kind]) != tr.Max+1 {
|
||||
fail("track texts", tr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
package main
|
||||
|
||||
// Claim-all against a real database: TestClaimGolden plays fixed scenarios (one-well and
|
||||
// multi-well drills on shared wells, wells emptied mid-claim, redeploy on and off, every
|
||||
// scope, reach 1, 3 and 5) and compares what they leave in the database with
|
||||
// testdata/claim_golden.txt; TestClaimAllPerf times claim-all and counts its statements.
|
||||
// Both need MM_TEST_DATABASE, a migrated database of their own (scripts/wt makes one:
|
||||
// mm_<task>); without it they are skipped. MM_CLAIM_GOLDEN_WRITE=1 rewrites the golden file.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// countTracer counts the statements a pool sends and keeps the slowest.
|
||||
type countTracer struct {
|
||||
n atomic.Int64
|
||||
mu sync.Mutex
|
||||
slow []string
|
||||
}
|
||||
|
||||
type traceStart struct {
|
||||
sql string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (c *countTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, d pgx.TraceQueryStartData) context.Context {
|
||||
c.n.Add(1)
|
||||
return context.WithValue(ctx, c, traceStart{d.SQL, time.Now()})
|
||||
}
|
||||
func (c *countTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryEndData) {
|
||||
if s, ok := ctx.Value(c).(traceStart); ok {
|
||||
if d := time.Since(s.at); d > 10*time.Millisecond {
|
||||
c.mu.Lock()
|
||||
c.slow = append(c.slow, fmt.Sprintf("%v %s", d.Round(time.Millisecond), strings.Join(strings.Fields(s.sql), " ")))
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
func (c *countTracer) TraceBatchStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceBatchStartData) context.Context {
|
||||
return ctx
|
||||
}
|
||||
func (c *countTracer) TraceBatchQuery(context.Context, *pgx.Conn, pgx.TraceBatchQueryData) {
|
||||
c.n.Add(1)
|
||||
}
|
||||
func (c *countTracer) TraceBatchEnd(context.Context, *pgx.Conn, pgx.TraceBatchEndData) {}
|
||||
|
||||
func testDB(t *testing.T) (*pgxpool.Pool, *countTracer) {
|
||||
t.Helper()
|
||||
name := os.Getenv("MM_TEST_DATABASE")
|
||||
if name == "" {
|
||||
t.Skip("MM_TEST_DATABASE not set")
|
||||
}
|
||||
if name == "moonminers" {
|
||||
t.Fatal("MM_TEST_DATABASE must not be the game's database")
|
||||
}
|
||||
if !loadDotEnv(".env") {
|
||||
loadDotEnv("../.env")
|
||||
}
|
||||
os.Setenv("DB_DATABASE", name)
|
||||
pc, err := pgxpool.ParseConfig(appDSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := &countTracer{}
|
||||
pc.ConnConfig.Tracer = tr
|
||||
db, err := pgxpool.NewWithConfig(context.Background(), pc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
return db, tr
|
||||
}
|
||||
|
||||
// fx builds players straight in the database and names every row it makes (labels), so
|
||||
// what a claim leaves can be compared across runs.
|
||||
type fx struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
db *pgxpool.Pool
|
||||
run uint32 // the first part of the ids the fixture makes (orders them as made)
|
||||
ids int
|
||||
label map[string]string // id → label
|
||||
res map[string]int // resource name → id
|
||||
resN []string // resource names, sorted
|
||||
clock int // seconds after base for the next stake
|
||||
action int
|
||||
}
|
||||
|
||||
var fxBase = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func newFx(t *testing.T, db *pgxpool.Pool) *fx {
|
||||
f := &fx{t: t, ctx: context.Background(), db: db, label: map[string]string{}, res: map[string]int{},
|
||||
run: rand.Uint32()}
|
||||
rows, err := db.Query(f.ctx, "select id, name from resource order by name")
|
||||
f.must(err)
|
||||
var id int
|
||||
var n string
|
||||
_, err = pgx.ForEachRow(rows, []any{&id, &n}, func() error {
|
||||
f.res[n] = id
|
||||
f.resN = append(f.resN, n)
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
if len(f.resN) < 5 {
|
||||
t.Fatalf("resources: %v", f.resN)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fx) must(err error) {
|
||||
f.t.Helper()
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fx) id(q string, args ...any) string {
|
||||
f.t.Helper()
|
||||
var id string
|
||||
f.must(f.db.QueryRow(f.ctx, q, args...).Scan(&id))
|
||||
return id
|
||||
}
|
||||
|
||||
func (f *fx) user(label string, onboarding []string, advisors ...string) string {
|
||||
if onboarding == nil {
|
||||
onboarding = []string{}
|
||||
}
|
||||
u := f.id("insert into users(name, onboarding) values ($1, $2) returning id::text", label, onboarding)
|
||||
f.label[u] = label
|
||||
f.t.Cleanup(func() {
|
||||
f.db.Exec(f.ctx, "delete from event_log where user_id = $1", u)
|
||||
f.db.Exec(f.ctx, "delete from users where id = $1", u)
|
||||
})
|
||||
_, err := f.db.Exec(f.ctx, "insert into bank_account(user_id, balance) values ($1, 1000)", u)
|
||||
f.must(err)
|
||||
_, err = f.db.Exec(f.ctx, `insert into resource_account(resource_id, user_id, balance)
|
||||
select id, $1, 0.1 * id from resource`, u)
|
||||
f.must(err)
|
||||
for i, a := range advisors {
|
||||
_, err = f.db.Exec(f.ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", u, a, i+1)
|
||||
f.must(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// moon makes a moon with wells of the given resources (indexes into resN) and supplies;
|
||||
// the wells are labelled <moon>.<i>.
|
||||
func (f *fx) moon(user, label string, res []int, supply []float64) []string {
|
||||
m := f.id(`insert into staking_source(name, description, user_id, address, grade)
|
||||
values ($1, '', $2, '', 'C') returning id::text`, label, user)
|
||||
f.label[m] = label
|
||||
var wells []string
|
||||
for i, r := range res {
|
||||
f.ids++
|
||||
w := f.id("insert into resource_well(id, resource_id, source_id, supply) values ($1, $2, $3, $4) returning id::text",
|
||||
fmt.Sprintf("%08x-0000-4000-8000-%012x", f.run, f.ids), f.res[f.resN[r%len(f.resN)]], m, supply[i])
|
||||
f.label[w] = fmt.Sprintf("%s.%d", label, i)
|
||||
wells = append(wells, w)
|
||||
}
|
||||
return wells
|
||||
}
|
||||
|
||||
func (f *fx) tool(user, label string, item, yl, cl int) string {
|
||||
id := f.id("insert into inventory_item(user_id, store_item_id, yield_level, cycle_level) values ($1, $2, $3, $4) returning id::text",
|
||||
user, item, yl, cl)
|
||||
f.label[id] = label
|
||||
return id
|
||||
}
|
||||
|
||||
// stake puts a stake in: ready (started at the next tick of the fixture clock, long ago)
|
||||
// or still running.
|
||||
func (f *fx) stake(user, label, tool, well string, amount float64, reach int, ready bool) string {
|
||||
f.clock++
|
||||
at, secs := fxBase.Add(time.Duration(f.clock)*time.Second), 30.0
|
||||
if !ready {
|
||||
at, secs = time.Now().Add(time.Duration(f.clock)*time.Second), 1e8
|
||||
}
|
||||
id := f.id(`insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
|
||||
values ($1, $2, $3, $4, $5, $6, $7) returning id::text`, user, well, tool, secs, amount, reach, at)
|
||||
f.label[id] = label
|
||||
return id
|
||||
}
|
||||
|
||||
// finish makes every running stake of the player ready, in the order of their tools' labels
|
||||
// (deterministic, unlike the random ids that order stakes started together).
|
||||
func (f *fx) finish(user string) {
|
||||
rows, err := f.db.Query(f.ctx, `select id::text, inventory_item_id::text from staking_event
|
||||
where user_id = $1 and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`, user)
|
||||
f.must(err)
|
||||
type st struct{ ID, Tool string }
|
||||
stakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[st])
|
||||
f.must(err)
|
||||
sort.Slice(stakes, func(i, j int) bool { return f.label[stakes[i].Tool] < f.label[stakes[j].Tool] })
|
||||
for _, s := range stakes {
|
||||
f.clock++
|
||||
_, err := f.db.Exec(f.ctx, "update staking_event set created_at = $2, duration_in_secs = least(duration_in_secs, 30) where id = $1",
|
||||
s.ID, fxBase.Add(time.Duration(f.clock)*time.Second))
|
||||
f.must(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fnum(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
|
||||
|
||||
// claim runs claimReady and writes the result and the player's whole state to out.
|
||||
func (f *fx) claim(out *strings.Builder, user string, scope ClaimScope, what string) {
|
||||
f.action++
|
||||
var lastLog int64
|
||||
f.must(f.db.QueryRow(f.ctx, "select coalesce(max(id), 0) from event_log where user_id = $1", user).Scan(&lastLog))
|
||||
res, err := claimReady(f.ctx, f.db, user, scope)
|
||||
fmt.Fprintf(out, "== %s %s: %s\n", f.label[user], what, f.lbl(scope.MoonID+scope.WellID))
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "error: %v\n", err)
|
||||
} else {
|
||||
var fin []string
|
||||
for _, m := range res.Finished {
|
||||
fin = append(fin, f.label[m.ID]+"="+m.Name)
|
||||
}
|
||||
fmt.Fprintf(out, "claimed %d redeployed %d total %s stopped %d finished %v\n",
|
||||
res.Claimed, res.Redeployed, fnum(res.Total), res.Stopped, fin)
|
||||
}
|
||||
f.dump(out, user, lastLog)
|
||||
}
|
||||
|
||||
func (f *fx) lbl(id string) string {
|
||||
if l, ok := f.label[id]; ok {
|
||||
return l
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (f *fx) dump(out *strings.Builder, user string, lastLog int64) {
|
||||
q := func(sql string, args ...any) pgx.Rows {
|
||||
rows, err := f.db.Query(f.ctx, sql, args...)
|
||||
f.must(err)
|
||||
return rows
|
||||
}
|
||||
// Name the stakes this action made: tool@action.
|
||||
rows := q("select id::text, inventory_item_id::text from staking_event where user_id = $1", user)
|
||||
var id, tool string
|
||||
_, err := pgx.ForEachRow(rows, []any{&id, &tool}, func() error {
|
||||
if _, ok := f.label[id]; !ok {
|
||||
f.label[id] = fmt.Sprintf("%s@%d", f.label[tool], f.action)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
|
||||
var lines []string
|
||||
rows = q(`select resource_well.id::text, supply from resource_well
|
||||
join staking_source on staking_source.id = source_id where user_id = $1`, user)
|
||||
var supply float64
|
||||
_, err = pgx.ForEachRow(rows, []any{&id, &supply}, func() error {
|
||||
lines = append(lines, fmt.Sprintf("well %s %s", f.lbl(id), fnum(supply)))
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
sort.Strings(lines)
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
|
||||
lines = nil
|
||||
rows = q(`select resource.name, balance from resource_account join resource on resource.id = resource_id
|
||||
where user_id = $1`, user)
|
||||
var name string
|
||||
var bal float64
|
||||
_, err = pgx.ForEachRow(rows, []any{&name, &bal}, func() error {
|
||||
lines = append(lines, fmt.Sprintf("balance %s %s", name, fnum(bal)))
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
sort.Strings(lines)
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
|
||||
var flags []string
|
||||
f.must(f.db.QueryRow(f.ctx, "select onboarding from users where id = $1", user).Scan(&flags))
|
||||
slices.Sort(flags)
|
||||
fmt.Fprintf(out, "onboarding %v\n", flags)
|
||||
|
||||
lines = nil
|
||||
rows = q(`select staking_event.id::text, well_id::text, inventory_item_id::text, duration_in_secs, stake_amount,
|
||||
staking_event.reach, claim_event.claim_amount,
|
||||
coalesce((select string_agg(cw.well_id::text || '=' || cw.amount::text, ',') from claim_well cw
|
||||
where cw.claim_event_id = claim_event.id), '')
|
||||
from staking_event left join claim_event on claim_event.staking_event_id = staking_event.id
|
||||
where staking_event.user_id = $1`, user)
|
||||
var well string
|
||||
var dur, amount float64
|
||||
var reach int
|
||||
var claim *float64
|
||||
var cw string
|
||||
_, err = pgx.ForEachRow(rows, []any{&id, &well, &tool, &dur, &amount, &reach, &claim, &cw}, func() error {
|
||||
c := "running"
|
||||
if claim != nil {
|
||||
c = "claimed " + fnum(*claim)
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range strings.Split(cw, ",") {
|
||||
if w, a, ok := strings.Cut(p, "="); ok {
|
||||
parts = append(parts, f.lbl(w)+"="+a)
|
||||
}
|
||||
}
|
||||
slices.Sort(parts)
|
||||
lines = append(lines, fmt.Sprintf("stake %s well %s tool %s secs %s amount %s reach %d %s %v",
|
||||
f.lbl(id), f.lbl(well), f.lbl(tool), fnum(dur), fnum(amount), reach, c, parts))
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
sort.Strings(lines)
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
|
||||
// The event log in order; runs of stake_stopped (ordered by start time only, ties
|
||||
// at random) sorted.
|
||||
lines = nil
|
||||
rows = q("select kind, data::text from event_log where user_id = $1 and id > $2 order by id", user, lastLog)
|
||||
var kind, data string
|
||||
var run []string
|
||||
flush := func() {
|
||||
slices.Sort(run)
|
||||
lines = append(lines, run...)
|
||||
run = nil
|
||||
}
|
||||
_, err = pgx.ForEachRow(rows, []any{&kind, &data}, func() error {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &m); err != nil {
|
||||
return err
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
var b strings.Builder
|
||||
for _, k := range keys {
|
||||
v := m[k]
|
||||
if s, ok := v.(string); ok {
|
||||
v = f.lbl(s)
|
||||
} else if x, ok := v.(float64); ok {
|
||||
v = fnum(x)
|
||||
}
|
||||
fmt.Fprintf(&b, " %s=%v", k, v)
|
||||
}
|
||||
line := "log " + kind + b.String()
|
||||
if kind == "stake_stopped" {
|
||||
run = append(run, line)
|
||||
return nil
|
||||
}
|
||||
flush()
|
||||
lines = append(lines, line)
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
flush()
|
||||
if len(lines) > 0 {
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// withConfig runs fn with the store items' reach replaced (item id → reach).
|
||||
func withReach(reach map[int]int, fn func()) {
|
||||
old := cfg()
|
||||
c := *old
|
||||
c.StoreItems = slices.Clone(old.StoreItems)
|
||||
for i := range c.StoreItems {
|
||||
if r, ok := reach[c.StoreItems[i].ID]; ok {
|
||||
c.StoreItems[i].Reach = r
|
||||
}
|
||||
}
|
||||
effective.Store(&c)
|
||||
defer effective.Store(old)
|
||||
fn()
|
||||
}
|
||||
|
||||
// seqStakeIDs makes new staking_event ids come from a sequence, in the order the stakes
|
||||
// are made, whether the database makes them (the column default) or the server
|
||||
// (newStakeID): stakes started together are ordered by id, which is otherwise random.
|
||||
func seqStakeIDs(t *testing.T, db *pgxpool.Pool) {
|
||||
ctx := context.Background()
|
||||
admin, err := pgxpool.New(ctx, adminDSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Close()
|
||||
const id = `('00000000-0000-4000-8000-' || lpad(to_hex(nextval('claim_test_seq')), 12, '0'))::uuid`
|
||||
for _, q := range []string{
|
||||
"create sequence if not exists claim_test_seq",
|
||||
"grant usage, select on sequence claim_test_seq to " + pgx.Identifier{env("DB_USER", "moonminers")}.Sanitize(),
|
||||
"alter table staking_event alter column id set default " + id,
|
||||
} {
|
||||
if _, err := admin.Exec(ctx, q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
oldID := newStakeID
|
||||
newStakeID = func() string {
|
||||
var s string
|
||||
if err := db.QueryRow(ctx, "select "+id+"::text").Scan(&s); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
newStakeID = oldID
|
||||
admin, err := pgxpool.New(ctx, adminDSN())
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec(ctx, "alter table staking_event alter column id set default gen_random_uuid()"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaimGolden(t *testing.T) {
|
||||
db, _ := testDB(t)
|
||||
seqStakeIDs(t, db)
|
||||
ctx := context.Background()
|
||||
// Live events for the redeployed stakes' amounts and durations; removed at the end.
|
||||
var evs []int
|
||||
for _, e := range []struct {
|
||||
kind, res string
|
||||
mult float64
|
||||
}{{"cycle_multiplier", "all", 1.5}, {"yield_multiplier", "Sollux", 1.25}, {"yield_multiplier", "Azurium", 0.8}} {
|
||||
var id int
|
||||
err := db.QueryRow(ctx, `insert into live_event(name, kind, params, starts_at, ends_at)
|
||||
values ('claim test', $1, $2, now() - interval '1 day', now() + interval '1 day') returning id`,
|
||||
e.kind, map[string]any{"resource": e.res, "multiplier": e.mult}).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evs = append(evs, id)
|
||||
}
|
||||
defer db.Exec(ctx, "delete from live_event where id = any($1)", evs)
|
||||
|
||||
var out strings.Builder
|
||||
run := func(cfgName string) {
|
||||
f := newFx(t, db)
|
||||
claimScenarios(f, &out, cfgName)
|
||||
}
|
||||
run("reach5")
|
||||
withReach(map[int]int{1: 1, 2: 1, 3: 3, 4: 2}, func() { run("mixed") })
|
||||
withReach(map[int]int{1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1}, func() { run("reach1") })
|
||||
|
||||
got := out.String()
|
||||
const path = "testdata/claim_golden.txt"
|
||||
if os.Getenv("MM_CLAIM_GOLDEN_WRITE") == "1" {
|
||||
if err := os.MkdirAll("testdata", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != string(want) {
|
||||
os.WriteFile(os.TempDir()+"/claim_golden.got", []byte(got), 0o644)
|
||||
gl, wl := strings.Split(got, "\n"), strings.Split(string(want), "\n")
|
||||
for i := 0; i < min(len(gl), len(wl)); i++ {
|
||||
if gl[i] != wl[i] {
|
||||
t.Fatalf("differs at line %d (full output in %s/claim_golden.got):\n got: %s\nwant: %s",
|
||||
i+1, os.TempDir(), gl[i], wl[i])
|
||||
}
|
||||
}
|
||||
t.Fatalf("differs in length: got %d lines, want %d", len(gl), len(wl))
|
||||
}
|
||||
}
|
||||
|
||||
// claimScenarios plays every scenario for one config; p prefixes the labels.
|
||||
func claimScenarios(f *fx, out *strings.Builder, p string) {
|
||||
all := ClaimScope{}
|
||||
redeploy := ClaimScope{Redeploy: true}
|
||||
|
||||
// 1. One-well drills overdrilling a well: the third claim empties it and stops the
|
||||
// fourth (ready, later in the list) and a running one; a multi-well drill on the
|
||||
// emptied well; claim-all with redeploy, then the redeployed drills again.
|
||||
u := f.user(p+"-overdrill", nil)
|
||||
w := f.moon(u, p+"-m1", []int{3, 0, 4}, []float64{10, 50, 5})
|
||||
for i, item := range []int{1, 2, 1, 3} {
|
||||
f.stake(u, fmt.Sprintf("%s-s%d", p, i), f.tool(u, fmt.Sprintf("%s-t%d", p, i), item, 0, 0), w[0], 4, 1, true)
|
||||
}
|
||||
f.stake(u, p+"-run", f.tool(u, p+"-t4", 1, 0, 0), w[0], 3, 1, false)
|
||||
f.stake(u, p+"-wide", f.tool(u, p+"-t5", 5, 1, 0), w[2], 7, 5, true)
|
||||
f.stake(u, p+"-az", f.tool(u, p+"-t6", 2, 0, 1), w[1], 9, 1, true)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy")
|
||||
f.finish(u)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy 2")
|
||||
f.finish(u)
|
||||
f.claim(out, u, all, "claim-all")
|
||||
|
||||
// 2. Multi-well drills on a 7-well moon (reach 5 covers part of it) and a 4-well moon,
|
||||
// wells emptied by multi-well claims stopping one-well drills; a moon that finishes.
|
||||
u = f.user(p+"-wide", []string{"market"}, "Ada Voss", "Rook Halvorsen")
|
||||
w7 := f.moon(u, p+"-m7", []int{0, 1, 2, 3, 4, 0, 1}, []float64{3, 8, 0, 12, 2.5, 6, 1})
|
||||
w4 := f.moon(u, p+"-m4", []int{4, 3, 2, 1}, []float64{2, 2, 1, 0.5})
|
||||
n := 0
|
||||
tl := func(item, yl, cl int) string {
|
||||
n++
|
||||
return f.tool(u, fmt.Sprintf("%s-w%d", p, n), item, yl, cl)
|
||||
}
|
||||
f.stake(u, p+"-a1", tl(4, 0, 0), w7[0], 9, 5, true)
|
||||
f.stake(u, p+"-a2", tl(1, 0, 0), w7[3], 5, 1, true)
|
||||
f.stake(u, p+"-a3", tl(5, 2, 1), w7[6], 6, 5, true)
|
||||
f.stake(u, p+"-a4", tl(1, 1, 0), w7[1], 2, 1, true)
|
||||
f.stake(u, p+"-a5", tl(3, 0, 0), w7[2], 4, 3, true)
|
||||
f.stake(u, p+"-a6", tl(1, 0, 0), w7[4], 3, 1, false)
|
||||
f.stake(u, p+"-a7", tl(6, 0, 0), w7[5], 20, 5, false)
|
||||
f.stake(u, p+"-b1", tl(2, 0, 0), w4[0], 1.5, 1, true)
|
||||
f.stake(u, p+"-b2", tl(7, 0, 0), w4[1], 3, 5, true)
|
||||
f.stake(u, p+"-b3", tl(1, 0, 0), w4[3], 1, 1, true)
|
||||
f.stake(u, p+"-b4", tl(4, 0, 0), w4[2], 2, 3, true)
|
||||
m7 := f.moonOf(w7[0])
|
||||
f.claim(out, u, ClaimScope{MoonID: m7}, "claim-moon m7")
|
||||
f.claim(out, u, ClaimScope{MoonID: m7, Redeploy: true}, "claim-moon m7 redeploy")
|
||||
f.claim(out, u, ClaimScope{MoonID: f.moonOf(w4[0]), Wide: true, Redeploy: true}, "claim-wide m4 redeploy")
|
||||
f.claim(out, u, ClaimScope{WellID: w4[0], Redeploy: true}, "claim-well m4.0 redeploy")
|
||||
f.claim(out, u, ClaimScope{WellID: w4[3]}, "claim-well m4.3")
|
||||
f.finish(u)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy")
|
||||
f.finish(u)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy 2")
|
||||
f.finish(u)
|
||||
f.claim(out, u, all, "claim-all")
|
||||
f.claim(out, u, all, "claim-all nothing")
|
||||
|
||||
// 3. A tool with a second unclaimed stake: redeploying it fails the whole claim.
|
||||
u = f.user(p+"-busy", []string{"all"})
|
||||
w = f.moon(u, p+"-mb", []int{0, 1}, []float64{20, 20})
|
||||
t := f.tool(u, p+"-bt", 1, 0, 0)
|
||||
f.stake(u, p+"-bs1", t, w[0], 2, 1, true)
|
||||
f.stake(u, p+"-bs2", t, w[1], 2, 1, false)
|
||||
f.stake(u, p+"-bs3", f.tool(u, p+"-bt2", 2, 0, 0), w[1], 2, 1, true)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy busy")
|
||||
f.claim(out, u, all, "claim-all busy")
|
||||
|
||||
// 4. Many moons and drills with tight wells, seeded: three rounds of claim-all with
|
||||
// redeploy, then one without.
|
||||
u = f.user(p+"-many", nil, "Juno Park")
|
||||
rnd := rand.New(rand.NewSource(7))
|
||||
var moons [][]string
|
||||
for m := 0; m < 25; m++ {
|
||||
k := 1 + rnd.Intn(7)
|
||||
res, sup := make([]int, k), make([]float64, k)
|
||||
for i := range res {
|
||||
res[i] = rnd.Intn(5)
|
||||
sup[i] = float64(rnd.Intn(40)) / 2
|
||||
if rnd.Intn(6) == 0 {
|
||||
sup[i] = 0
|
||||
}
|
||||
}
|
||||
moons = append(moons, f.moon(u, fmt.Sprintf("%s-n%d", p, m), res, sup))
|
||||
}
|
||||
for i := 0; i < 120; i++ {
|
||||
ws := moons[rnd.Intn(len(moons))]
|
||||
item := 1 + rnd.Intn(7)
|
||||
reach := 1
|
||||
if rnd.Intn(2) == 0 {
|
||||
reach = []int{2, 3, 5}[rnd.Intn(3)]
|
||||
}
|
||||
tool := f.tool(u, fmt.Sprintf("%s-x%03d", p, i), item, rnd.Intn(3), rnd.Intn(3))
|
||||
f.stake(u, fmt.Sprintf("%s-y%03d", p, i), tool, ws[rnd.Intn(len(ws))], float64(1+rnd.Intn(16))/2, reach, rnd.Intn(5) != 0)
|
||||
}
|
||||
for r := 0; r < 3; r++ {
|
||||
f.claim(out, u, redeploy, fmt.Sprintf("claim-all redeploy round %d", r))
|
||||
f.finish(u)
|
||||
}
|
||||
f.claim(out, u, all, "claim-all final")
|
||||
}
|
||||
|
||||
func (f *fx) moonOf(well string) string {
|
||||
return f.id("select source_id::text from resource_well where id = $1", well)
|
||||
}
|
||||
|
||||
// TestClaimAllPerf times claim-all for players with n ready drills, and counts its
|
||||
// statements: one whole-moon drill per moon of three wells, or (crowded) n drills on 25
|
||||
// moons, a third of them one-well drills, more than the wells hold.
|
||||
func TestClaimAllPerf(t *testing.T) {
|
||||
db, tr := testDB(t)
|
||||
f := newFx(t, db)
|
||||
type perfRun struct {
|
||||
n int
|
||||
redeploy bool
|
||||
crowded bool
|
||||
}
|
||||
runs := []perfRun{{50, false, false}, {50, true, false}, {200, false, false}, {200, true, false},
|
||||
{900, false, false}, {900, true, false}, {900, false, true}, {900, true, true}}
|
||||
for _, run := range runs {
|
||||
n, moons, supply := run.n, run.n, []float64{40, 30, 3}
|
||||
if run.crowded {
|
||||
moons, supply = 25, []float64{300, 200, 8}
|
||||
}
|
||||
u := f.user(fmt.Sprintf("perf-%d-%v-%v", n, run.redeploy, run.crowded), nil)
|
||||
for m := 0; m < moons; m++ {
|
||||
f.moon(u, fmt.Sprintf("p%d-%d", n, m), []int{m, m + 1, m + 2}, supply)
|
||||
}
|
||||
_, err := db.Exec(f.ctx, `
|
||||
insert into inventory_item(user_id, store_item_id) select $1, 1 + (g % 7) from generate_series(1, $2) g`, u, n)
|
||||
f.must(err)
|
||||
_, err = db.Exec(f.ctx, `
|
||||
with tools as (select id, row_number() over (order by id) r from inventory_item where user_id = $1),
|
||||
moons as (select id, row_number() over (order by name) r from staking_source where user_id = $1)
|
||||
insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
|
||||
select $1, (select id from resource_well where source_id = moons.id order by id offset tools.r % 3 limit 1),
|
||||
tools.id, 10, 5 + tools.r % 4, case when $4 and tools.r % 3 = 0 then 1 else 5 end,
|
||||
$2::timestamptz + tools.r * interval '1 second'
|
||||
from tools join moons on moons.r = (tools.r - 1) % $3 + 1`, u, fxBase, moons, run.crowded)
|
||||
f.must(err)
|
||||
tr.mu.Lock()
|
||||
tr.slow = nil
|
||||
tr.mu.Unlock()
|
||||
start, n0 := time.Now(), tr.n.Load()
|
||||
res, err := claimReady(f.ctx, db, u, ClaimScope{Redeploy: run.redeploy})
|
||||
f.must(err)
|
||||
t.Logf("drills %d redeploy %v crowded %v: claimed %d redeployed %d stopped %d in %v, %d statements",
|
||||
n, run.redeploy, run.crowded, res.Claimed, res.Redeployed, res.Stopped,
|
||||
time.Since(start).Round(time.Millisecond), tr.n.Load()-n0)
|
||||
for _, s := range tr.slow {
|
||||
t.Logf(" slow: %.200s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -30,6 +31,8 @@ type GameConfig struct {
|
||||
StartingTools []int `json:"startingTools"` // store item ids, given free
|
||||
// Moon vendors, cheapest first (rollWells in game.go); the first is the default grade.
|
||||
MoonGrades []MoonGrade `json:"moonGrades"`
|
||||
// Packs of moons bought at once (buyMoons in game.go), smallest first.
|
||||
MoonPacks []MoonPack `json:"moonPacks,omitempty"`
|
||||
// Selling a moon back (resale.go).
|
||||
MoonResale MoonResale `json:"moonResale"`
|
||||
// Selling tools back (resale.go).
|
||||
@@ -68,6 +71,30 @@ type MoonGrade struct {
|
||||
MinValue float64 `json:"minValue,omitempty"`
|
||||
}
|
||||
|
||||
// MoonPack is count moons of one grade bought at once: discount off their price, and each
|
||||
// moon worth at least minValue × its grade's price (re-rolled like a grade's minValue,
|
||||
// whichever floor is higher).
|
||||
type MoonPack struct {
|
||||
Count int `json:"count"`
|
||||
Discount float64 `json:"discount"`
|
||||
MinValue float64 `json:"minValue,omitempty"`
|
||||
}
|
||||
|
||||
// pack is the moon pack of that size (nil: none, or a single moon).
|
||||
func (cfg *GameConfig) pack(count int) *MoonPack {
|
||||
for i := range cfg.MoonPacks {
|
||||
if cfg.MoonPacks[i].Count == count {
|
||||
return &cfg.MoonPacks[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PackPrice is what count moons of the grade cost in this pack.
|
||||
func (p *MoonPack) PackPrice(g *MoonGrade) float64 {
|
||||
return math.Round(float64(p.Count) * g.Price * (1 - p.Discount))
|
||||
}
|
||||
|
||||
// grade is the moon grade with that name, else the first (lowest) one.
|
||||
func (cfg *GameConfig) grade(name string) *MoonGrade {
|
||||
if i := cfg.gradeIndex(name); i >= 0 {
|
||||
@@ -162,6 +189,14 @@ func (cfg *GameConfig) validate() error {
|
||||
if err := cfg.MarketPressure.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, p := range cfg.MoonPacks {
|
||||
if p.Count < 2 || p.Discount < 0 || p.Discount >= 1 || p.MinValue < 0 || p.MinValue > 3 {
|
||||
return fmt.Errorf("moon pack %d: count at least 2, discount from 0 to below 1, minValue 0 to 3", i+1)
|
||||
}
|
||||
if i > 0 && p.Count <= cfg.MoonPacks[i-1].Count {
|
||||
return fmt.Errorf("moon packs: smallest first, no two the same size")
|
||||
}
|
||||
}
|
||||
if err := cfg.MoonResale.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+51
-49
@@ -3,7 +3,7 @@
|
||||
"startingResources": 0,
|
||||
"startingTools": [1],
|
||||
"moonGrades": [
|
||||
{"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 23, "wellSupplyMax": 46, "wellCountWeights": {"1": 40, "2": 60},
|
||||
{"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 28, "wellSupplyMax": 56, "wellCountWeights": {"1": 40, "2": 60},
|
||||
"affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}},
|
||||
{"grade": "C", "price": 12000, "minValue": 1.3, "wellSupplyMin": 432, "wellSupplyMax": 860, "wellCountWeights": {"2": 50, "3": 50},
|
||||
"affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}},
|
||||
@@ -14,6 +14,8 @@
|
||||
{"grade": "S", "price": 24000000, "wellSupplyMin": 337840, "wellSupplyMax": 829840, "wellCountWeights": {"4": 50, "5": 50},
|
||||
"affinities": {"Sollux": 0.4, "Shadowstone": 0.6, "Azurium": 1, "Novafor": 2, "Nebulance": 3.5}}
|
||||
],
|
||||
"moonPacks": [{"count": 5, "discount": 0.05, "minValue": 1.4}, {"count": 10, "discount": 0.1, "minValue": 1.5},
|
||||
{"count": 20, "discount": 0.15, "minValue": 1.6}],
|
||||
"moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600},
|
||||
"equipmentResale": {"factor": 0.5},
|
||||
"marketVolatility": 0.25,
|
||||
@@ -33,20 +35,20 @@
|
||||
"image": "shovel.png",
|
||||
"price": 1200,
|
||||
"claimAmount": 8,
|
||||
"completionTimeInSecs": 3,
|
||||
"completionTimeInSecs": 3, "reach": 5,
|
||||
"tracks": {
|
||||
"yield": {"name": "Twin rotors", "levels": [
|
||||
{"value": 10, "price": 280},
|
||||
{"value": 12, "price": 280},
|
||||
{"value": 14, "price": 290, "parts": {"Dusk Alloy": 1}},
|
||||
{"value": 16, "price": 280, "parts": {"Dusk Alloy": 2}}
|
||||
{"value": 14, "price": 290},
|
||||
{"value": 16, "price": 280}
|
||||
]},
|
||||
"cycle": {"name": "Flight computer", "levels": [
|
||||
{"value": 2.6, "price": 180},
|
||||
{"value": 2.2, "price": 240},
|
||||
{"value": 1.9, "price": 250, "parts": {"Dusk Alloy": 1}},
|
||||
{"value": 1.7, "price": 210, "parts": {"Dusk Alloy": 1}},
|
||||
{"value": 1.5, "price": 270, "parts": {"Dusk Alloy": 2}}
|
||||
{"value": 1.9, "price": 250},
|
||||
{"value": 1.7, "price": 210},
|
||||
{"value": 1.5, "price": 270}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -57,21 +59,21 @@
|
||||
"image": "pickaxe.png",
|
||||
"price": 8000,
|
||||
"claimAmount": 120,
|
||||
"completionTimeInSecs": 6,
|
||||
"completionTimeInSecs": 6, "reach": 5,
|
||||
"tracks": {
|
||||
"yield": {"name": "Wider chisel", "levels": [
|
||||
{"value": 144, "price": 1520},
|
||||
{"value": 168, "price": 1520},
|
||||
{"value": 192, "price": 1520, "parts": {"Dusk Alloy": 2}},
|
||||
{"value": 216, "price": 1520, "parts": {"Cryo Cell": 1}},
|
||||
{"value": 240, "price": 1520, "parts": {"Plasma Weave": 1}}
|
||||
{"value": 192, "price": 1520},
|
||||
{"value": 216, "price": 1520},
|
||||
{"value": 240, "price": 1520}
|
||||
]},
|
||||
"cycle": {"name": "Pneumatic hammer", "levels": [
|
||||
{"value": 5.2, "price": 1160},
|
||||
{"value": 4.5, "price": 1360},
|
||||
{"value": 3.9, "price": 1560, "parts": {"Dusk Alloy": 2}},
|
||||
{"value": 3.4, "price": 1720, "parts": {"Cryo Cell": 1}},
|
||||
{"value": 3, "price": 1780, "parts": {"Plasma Weave": 1}}
|
||||
{"value": 3.9, "price": 1560},
|
||||
{"value": 3.4, "price": 1720},
|
||||
{"value": 3, "price": 1780}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -82,21 +84,21 @@
|
||||
"image": "lunarlite.jpg",
|
||||
"price": 40200,
|
||||
"claimAmount": 900,
|
||||
"completionTimeInSecs": 8,
|
||||
"completionTimeInSecs": 8, "reach": 5,
|
||||
"tracks": {
|
||||
"yield": {"name": "Wide-bore bit", "levels": [
|
||||
{"value": 1060, "price": 6900},
|
||||
{"value": 1270, "price": 8700},
|
||||
{"value": 1430, "price": 6900, "parts": {"Cryo Cell": 1}},
|
||||
{"value": 1640, "price": 8700, "parts": {"Plasma Weave": 2}},
|
||||
{"value": 1800, "price": 6900, "parts": {"Plasma Weave": 3}}
|
||||
{"value": 1430, "price": 6900},
|
||||
{"value": 1640, "price": 8700},
|
||||
{"value": 1800, "price": 6900}
|
||||
]},
|
||||
"cycle": {"name": "Spindle motor", "levels": [
|
||||
{"value": 7, "price": 5400},
|
||||
{"value": 6, "price": 7200},
|
||||
{"value": 5.2, "price": 7800, "parts": {"Cryo Cell": 1}},
|
||||
{"value": 4.6, "price": 7800, "parts": {"Plasma Weave": 2}},
|
||||
{"value": 4, "price": 9900, "parts": {"Plasma Weave": 3}}
|
||||
{"value": 5.2, "price": 7800},
|
||||
{"value": 4.6, "price": 7800},
|
||||
{"value": 4, "price": 9900}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -108,21 +110,21 @@
|
||||
"price": 645000,
|
||||
"claimAmount": 20000,
|
||||
"completionTimeInSecs": 10,
|
||||
"reach": 2,
|
||||
"reach": 5,
|
||||
"tracks": {
|
||||
"yield": {"name": "Wider beam", "levels": [
|
||||
{"value": 23700, "price": 115000},
|
||||
{"value": 28200, "price": 135000},
|
||||
{"value": 31800, "price": 115000, "parts": {"Cryo Cell": 2}},
|
||||
{"value": 36300, "price": 135000, "parts": {"Plasma Weave": 3}},
|
||||
{"value": 40000, "price": 115000, "parts": {"Void Core": 1}}
|
||||
{"value": 31800, "price": 115000},
|
||||
{"value": 36300, "price": 135000},
|
||||
{"value": 40000, "price": 115000}
|
||||
]},
|
||||
"cycle": {"name": "Laser capacitors", "levels": [
|
||||
{"value": 8.7, "price": 90000},
|
||||
{"value": 7.5, "price": 115000},
|
||||
{"value": 6.5, "price": 125000, "parts": {"Cryo Cell": 2}},
|
||||
{"value": 5.7, "price": 130000, "parts": {"Plasma Weave": 3}},
|
||||
{"value": 5, "price": 150000, "parts": {"Void Core": 1}}
|
||||
{"value": 6.5, "price": 125000},
|
||||
{"value": 5.7, "price": 130000},
|
||||
{"value": 5, "price": 150000}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -134,21 +136,21 @@
|
||||
"price": 9440000,
|
||||
"claimAmount": 384000,
|
||||
"completionTimeInSecs": 12,
|
||||
"reach": 3,
|
||||
"reach": 5,
|
||||
"tracks": {
|
||||
"yield": {"name": "Fine sieves", "levels": [
|
||||
{"value": 452000, "price": 1600000},
|
||||
{"value": 542000, "price": 2080000},
|
||||
{"value": 610000, "price": 1600000, "parts": {"Plasma Weave": 3}},
|
||||
{"value": 700000, "price": 2080000, "parts": {"Void Core": 1}},
|
||||
{"value": 768000, "price": 1600000, "parts": {"Stellar Lattice": 1}}
|
||||
{"value": 610000, "price": 1600000},
|
||||
{"value": 700000, "price": 2080000},
|
||||
{"value": 768000, "price": 1600000}
|
||||
]},
|
||||
"cycle": {"name": "Auger gearbox", "levels": [
|
||||
{"value": 10.4, "price": 1360000},
|
||||
{"value": 9, "price": 1600000},
|
||||
{"value": 7.8, "price": 1840000, "parts": {"Plasma Weave": 3}},
|
||||
{"value": 6.8, "price": 2000000, "parts": {"Void Core": 1}},
|
||||
{"value": 6, "price": 2080000, "parts": {"Stellar Lattice": 1}}
|
||||
{"value": 7.8, "price": 1840000},
|
||||
{"value": 6.8, "price": 2000000},
|
||||
{"value": 6, "price": 2080000}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -165,16 +167,16 @@
|
||||
"yield": {"name": "Larger plates", "levels": [
|
||||
{"value": 1050000, "price": 3120000},
|
||||
{"value": 1220000, "price": 3120000},
|
||||
{"value": 1420000, "price": 3600000, "parts": {"Void Core": 1}},
|
||||
{"value": 1590000, "price": 3120000, "parts": {"Stellar Lattice": 1}},
|
||||
{"value": 1760000, "price": 3120000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
|
||||
{"value": 1420000, "price": 3600000},
|
||||
{"value": 1590000, "price": 3120000},
|
||||
{"value": 1760000, "price": 3120000}
|
||||
]},
|
||||
"cycle": {"name": "Charge coils", "levels": [
|
||||
{"value": 12.2, "price": 2400000},
|
||||
{"value": 10.5, "price": 2960000},
|
||||
{"value": 9.1, "price": 3280000, "parts": {"Void Core": 1}},
|
||||
{"value": 8, "price": 3440000, "parts": {"Stellar Lattice": 1}},
|
||||
{"value": 7, "price": 4000000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
|
||||
{"value": 9.1, "price": 3280000},
|
||||
{"value": 8, "price": 3440000},
|
||||
{"value": 7, "price": 4000000}
|
||||
]}
|
||||
}
|
||||
},
|
||||
@@ -191,16 +193,16 @@
|
||||
"yield": {"name": "Core barrel", "levels": [
|
||||
{"value": 1910000, "price": 4480000},
|
||||
{"value": 2250000, "price": 5040000},
|
||||
{"value": 2550000, "price": 4480000, "parts": {"Void Core": 2}},
|
||||
{"value": 2890000, "price": 5040000, "parts": {"Stellar Lattice": 2}},
|
||||
{"value": 3200000, "price": 4480000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
|
||||
{"value": 2550000, "price": 4480000},
|
||||
{"value": 2890000, "price": 5040000},
|
||||
{"value": 3200000, "price": 4480000}
|
||||
]},
|
||||
"cycle": {"name": "Drill string", "levels": [
|
||||
{"value": 14, "price": 3360000},
|
||||
{"value": 12, "price": 4480000},
|
||||
{"value": 10.4, "price": 4800000, "parts": {"Void Core": 2}},
|
||||
{"value": 9.1, "price": 5200000, "parts": {"Stellar Lattice": 2}},
|
||||
{"value": 8, "price": 5680000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
|
||||
{"value": 10.4, "price": 4800000},
|
||||
{"value": 9.1, "price": 5200000},
|
||||
{"value": 8, "price": 5680000}
|
||||
]}
|
||||
}
|
||||
}
|
||||
@@ -269,7 +271,7 @@
|
||||
"bulkLog": 1.0,
|
||||
"unlockGrade": "C",
|
||||
"items": [
|
||||
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 24, "seconds": 1,
|
||||
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 28, "seconds": 1,
|
||||
"inputs": {"Sollux": 2, "Shadowstone": 2},
|
||||
"description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."},
|
||||
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 97, "seconds": 2,
|
||||
@@ -278,7 +280,7 @@
|
||||
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 134, "seconds": 2,
|
||||
"inputs": {"Nebulance": 4, "Sollux": 2},
|
||||
"description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."},
|
||||
{"name": "Void Core", "tier": 2, "unlockGrade": "B", "color": "#c4b5fd", "price": 450, "seconds": 3,
|
||||
{"name": "Void Core", "tier": 2, "unlockGrade": "B", "color": "#c4b5fd", "price": 460, "seconds": 3,
|
||||
"inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5},
|
||||
"description": "A cold, dense power core that drives the heaviest rigs for weeks."},
|
||||
{"name": "Stellar Lattice", "tier": 2, "unlockGrade": "B", "color": "#fde047", "price": 470, "seconds": 3,
|
||||
|
||||
+744
-99
File diff suppressed because it is too large.
Load diff
+89
-96
@@ -15,6 +15,8 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -73,9 +75,84 @@ func floorDiv(a, b int64) int64 {
|
||||
return q
|
||||
}
|
||||
|
||||
// marketRateAt is the market price of a unit at a tick, before live events.
|
||||
// marketRateAt is the market price of a unit at a tick, before live events. Ticks in the
|
||||
// cached 24h window (marketSeries) are read from it; the current tick fills it.
|
||||
func marketRateAt(name string, tick int64) float64 {
|
||||
return cfg().unitPrice(name) * (1 + cfg().MarketVolatility*marketDrift(name, tick))
|
||||
c := cfg()
|
||||
if mc := marketCached.Load(); mc != nil && mc.cfg == c && tick <= mc.tick && tick > mc.tick-marketPoints {
|
||||
return mc.get(name).series[marketPoints-1-int(mc.tick-tick)]
|
||||
}
|
||||
if d := tick - marketTick(time.Now()); d >= -1 && d <= 1 {
|
||||
return marketSeriesAt(name, tick).rate
|
||||
}
|
||||
return rateAt(c, name, tick)
|
||||
}
|
||||
|
||||
func rateAt(c *GameConfig, name string, tick int64) float64 {
|
||||
return c.unitPrice(name) * (1 + c.MarketVolatility*marketDrift(name, tick))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ shared 24h series
|
||||
|
||||
// marketSeries is an item's market rate over the 24h up to a tick (marketPoints, oldest
|
||||
// first) and what the market shows of it. It's shared by every player: read it, never
|
||||
// modify it. Live price events aren't in it (they multiply the quote per request), so
|
||||
// they never make it stale.
|
||||
type marketSeries struct {
|
||||
series []float64
|
||||
rate, open, high, low, change float64
|
||||
}
|
||||
|
||||
// marketTickCache holds the series of one tick under one config snapshot (cfg() is a new
|
||||
// pointer whenever the economy editor or another instance changes the config).
|
||||
type marketTickCache struct {
|
||||
cfg *GameConfig
|
||||
tick int64
|
||||
items sync.Map // name → *marketSeries
|
||||
}
|
||||
|
||||
var marketCached atomic.Pointer[marketTickCache]
|
||||
|
||||
func (mc *marketTickCache) get(name string) *marketSeries {
|
||||
if v, ok := mc.items.Load(name); ok {
|
||||
return v.(*marketSeries)
|
||||
}
|
||||
v, _ := mc.items.LoadOrStore(name, newMarketSeries(mc.cfg, name, mc.tick))
|
||||
return v.(*marketSeries)
|
||||
}
|
||||
|
||||
// marketSeriesAt is an item's series up to tick, cached for the newest tick asked for.
|
||||
func marketSeriesAt(name string, tick int64) *marketSeries {
|
||||
c := cfg()
|
||||
mc := marketCached.Load()
|
||||
if mc == nil || mc.cfg != c || mc.tick < tick {
|
||||
next := &marketTickCache{cfg: c, tick: tick}
|
||||
if marketCached.CompareAndSwap(mc, next) {
|
||||
mc = next
|
||||
} else {
|
||||
mc = marketCached.Load()
|
||||
}
|
||||
}
|
||||
if mc == nil || mc.cfg != c || mc.tick != tick {
|
||||
return newMarketSeries(c, name, tick) // an older tick, or a racing config change
|
||||
}
|
||||
return mc.get(name)
|
||||
}
|
||||
|
||||
func newMarketSeries(c *GameConfig, name string, tick int64) *marketSeries {
|
||||
m := &marketSeries{series: make([]float64, marketPoints)}
|
||||
for i := range m.series {
|
||||
m.series[i] = rateAt(c, name, tick-int64(marketPoints-1-i))
|
||||
}
|
||||
m.rate, m.open = m.series[marketPoints-1], m.series[0]
|
||||
m.high, m.low = m.series[0], m.series[0]
|
||||
for _, p := range m.series {
|
||||
m.high, m.low = max(m.high, p), min(m.low, p)
|
||||
}
|
||||
if m.open > 0 {
|
||||
m.change = (m.rate/m.open - 1) * 100
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// marketRate is the market price of a unit at time t, before live events.
|
||||
@@ -250,10 +327,9 @@ type Quote struct {
|
||||
High float64 // 24h high/low of the market rate
|
||||
Low float64
|
||||
Value float64 // Balance × Price (at the quote)
|
||||
Spark string // SVG path, viewBox 0 0 100 32
|
||||
URL string
|
||||
Selected bool
|
||||
series []float64
|
||||
series []float64 // the shared 24h market rate (marketSeries): read only
|
||||
}
|
||||
|
||||
func (q Quote) Up() bool { return q.Change >= 0 }
|
||||
@@ -311,34 +387,12 @@ func marketSymbol(name string) string {
|
||||
return strings.ToUpper(name)
|
||||
}
|
||||
|
||||
// MarketChart is the selected quote's 24h chart, viewBox 0 0 ChartW ChartH.
|
||||
// MarketChart is the selected quote's 24h chart: its points (the quote's series) start at
|
||||
// Start, unix ms, Step ms apart.
|
||||
type MarketChart struct {
|
||||
Line, Area string
|
||||
Prices string // comma-separated, for the crosshair
|
||||
Start, Step int64 // unix ms of the first point, ms between points
|
||||
HighY, LowY float64
|
||||
Grid []GridLine
|
||||
Times []TimeLabel
|
||||
Start, Step int64
|
||||
}
|
||||
|
||||
type GridLine struct {
|
||||
Y float64 // in viewBox units
|
||||
Top float64 // the same, as a percent of the height
|
||||
Label string
|
||||
}
|
||||
|
||||
type TimeLabel struct {
|
||||
X float64 // percent
|
||||
Label string
|
||||
}
|
||||
|
||||
const (
|
||||
chartW = 600
|
||||
chartH = 220
|
||||
sparkW = 100
|
||||
sparkH = 32
|
||||
)
|
||||
|
||||
// marketPage builds the market from the rows the market section lists (resources and
|
||||
// refined items).
|
||||
func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
||||
@@ -347,23 +401,13 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
||||
for _, row := range rows {
|
||||
q := Quote{MarketRow: row, Boost: s.PriceBoosts.For(row.Resource), Advisor: s.Bonus().Sell,
|
||||
URL: View{Section: "market", Moon: v.Moon, Pick: row.Resource}.URL()}
|
||||
q.series = make([]float64, marketPoints)
|
||||
for i := range q.series {
|
||||
q.series[i] = marketRateAt(row.Resource, now-int64(marketPoints-1-i))
|
||||
}
|
||||
q.Rate, q.Open = q.series[marketPoints-1], q.series[0]
|
||||
q.High, q.Low = q.series[0], q.series[0]
|
||||
for _, p := range q.series {
|
||||
q.High, q.Low = max(q.High, p), min(q.Low, p)
|
||||
}
|
||||
if q.Open > 0 {
|
||||
q.Change = (q.Rate/q.Open - 1) * 100
|
||||
}
|
||||
m := marketSeriesAt(row.Resource, now)
|
||||
q.series = m.series
|
||||
q.Rate, q.Open, q.High, q.Low, q.Change = m.rate, m.open, m.high, m.low, m.change
|
||||
q.Pressure = s.Pressure[row.Resource]
|
||||
q.Factor = cfg().MarketPressure.Factor(q.Pressure)
|
||||
q.Price = q.Rate * q.Boost
|
||||
q.Value = q.Balance * q.Price
|
||||
q.Spark, _ = chartPaths(q.series, 3, sparkW, sparkH, q.Low, q.High)
|
||||
mp.Quotes = append(mp.Quotes, q)
|
||||
}
|
||||
for i := range mp.Quotes {
|
||||
@@ -387,62 +431,11 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
||||
}
|
||||
if q := mp.Selected; q != nil {
|
||||
q.Selected = true
|
||||
mp.Chart = s.marketChart(q, now)
|
||||
mp.Chart = marketChart(now)
|
||||
}
|
||||
return mp
|
||||
}
|
||||
|
||||
func (s *State) marketChart(q *Quote, now int64) MarketChart {
|
||||
// Pad the range so the line doesn't touch the edges.
|
||||
pad := max((q.High-q.Low)*0.12, q.Rate*0.005)
|
||||
lo, hi := q.Low-pad, q.High+pad
|
||||
c := MarketChart{Step: marketStep.Milliseconds(),
|
||||
Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
|
||||
c.Line, c.Area = chartPaths(q.series, 1, chartW, chartH, lo, hi)
|
||||
y := func(p float64) float64 { return chartH - (p-lo)/(hi-lo)*chartH }
|
||||
c.HighY, c.LowY = y(q.High), y(q.Low)
|
||||
prices := make([]string, len(q.series))
|
||||
for i, p := range q.series {
|
||||
prices[i] = strconv.FormatFloat(p, 'f', 4, 64)
|
||||
}
|
||||
c.Prices = strings.Join(prices, ",")
|
||||
for i := 1; i <= 3; i++ {
|
||||
p := lo + (hi-lo)*float64(i)/4
|
||||
c.Grid = append(c.Grid, GridLine{Y: y(p), Top: y(p) / chartH * 100, Label: formatCredits(p)})
|
||||
}
|
||||
for h := 0; h <= 24; h += 6 {
|
||||
label := fmt.Sprintf("−%dh", 24-h)
|
||||
if h == 24 {
|
||||
label = "Now"
|
||||
}
|
||||
c.Times = append(c.Times, TimeLabel{X: float64(h) / 24 * 100, Label: label})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// chartPaths returns an SVG line path through every nth point of series, scaled to
|
||||
// w×h between lo and hi, and the area under it.
|
||||
func chartPaths(series []float64, every int, w, h, lo, hi float64) (line, area string) {
|
||||
if len(series) < 2 {
|
||||
return "", ""
|
||||
}
|
||||
if hi <= lo {
|
||||
lo, hi = lo-1, hi+1
|
||||
}
|
||||
var b strings.Builder
|
||||
last := len(series) - 1
|
||||
for i := 0; i <= last; i += every {
|
||||
if i+every > last {
|
||||
i = last
|
||||
}
|
||||
x := float64(i) / float64(last) * w
|
||||
y := h - (series[i]-lo)/(hi-lo)*h
|
||||
cmd := "L"
|
||||
if i == 0 {
|
||||
cmd = "M"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%.1f %.1f", cmd, x, y)
|
||||
}
|
||||
line = b.String()
|
||||
return line, fmt.Sprintf("%sL%.1f %.1fL0 %.1fZ", line, w, h, h)
|
||||
func marketChart(now int64) MarketChart {
|
||||
return MarketChart{Step: marketStep.Milliseconds(), Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
|
||||
}
|
||||
@@ -0,0 +1,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("", " ")
|
||||
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)
|
||||
}
|
||||
+18
-2
@@ -36,6 +36,15 @@ type GradeOffer struct {
|
||||
SupplyMin float64 // a well holds from SupplyMin to just under SupplyMax
|
||||
SupplyMax float64
|
||||
Default bool // what a buy without a grade gets
|
||||
Packs []PackOffer
|
||||
}
|
||||
|
||||
// PackOffer is a moon pack of a grade (config moonPacks), once the store is open.
|
||||
type PackOffer struct {
|
||||
Count int
|
||||
Price float64
|
||||
Discount float64
|
||||
CanAfford bool
|
||||
}
|
||||
|
||||
// GradeOffers lists every moon grade, cheapest first.
|
||||
@@ -44,9 +53,16 @@ func (s *State) GradeOffers() []GradeOffer {
|
||||
var out []GradeOffer
|
||||
for _, g := range cfg().MoonGrades {
|
||||
lo, hi := g.wellCountRange()
|
||||
out = append(out, GradeOffer{Grade: g.Grade, Price: g.Price, CanAfford: s.Balance >= g.Price,
|
||||
o := GradeOffer{Grade: g.Grade, Price: g.Price, CanAfford: s.Balance >= g.Price,
|
||||
WellsMin: lo, WellsMax: hi, SupplyMin: g.WellSupplyMin, SupplyMax: g.WellSupplyMax,
|
||||
Default: g.Grade == def})
|
||||
Default: g.Grade == def}
|
||||
if s.Unlocked("store") {
|
||||
for _, p := range cfg().MoonPacks {
|
||||
price := p.PackPrice(&g)
|
||||
o.Packs = append(o.Packs, PackOffer{Count: p.Count, Price: price, Discount: p.Discount, CanAfford: s.Balance >= price})
|
||||
}
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+23
-7
@@ -68,10 +68,17 @@ func serve(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, s.static, "favicon.ico")
|
||||
})
|
||||
// A Defold web build will be served here later; for now the admin.
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/utils", http.StatusSeeOther)
|
||||
})
|
||||
// The game's web build (client: make web) at /, from MM_WEB_DIR (default: the client's
|
||||
// dist/web/MoonMiners beside this checkout); without one, / goes to the admin.
|
||||
webDir := env("MM_WEB_DIR", "../client/dist/web/MoonMiners")
|
||||
if st, err := os.Stat(webDir + "/index.html"); err == nil && !st.IsDir() {
|
||||
// "/", not "GET /": that would conflict with the method-less /utils patterns (panic)
|
||||
mux.Handle("/", http.FileServerFS(os.DirFS(webDir)))
|
||||
} else {
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/utils", http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
mux.HandleFunc("GET /utils/login", s.handleLoginPage)
|
||||
mux.HandleFunc("POST /utils/login", s.handleLogin)
|
||||
mux.HandleFunc("POST /utils/logout", s.handleLogout)
|
||||
@@ -346,9 +353,18 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
|
||||
switch action {
|
||||
case "refused":
|
||||
case "buy-moon":
|
||||
var moonID string
|
||||
if moonID, err = buyMoon(ctx, s.db, userID, get("grade")); err == nil {
|
||||
message, view = "New moon claimed!", View{Section: "moons", Moon: moonID}
|
||||
count := 1
|
||||
if c := get("count"); c != "" {
|
||||
if count, err = strconv.Atoi(c); err != nil {
|
||||
count = 0 // (refused: no pack of that size)
|
||||
}
|
||||
}
|
||||
var ids []string
|
||||
if ids, err = buyMoons(ctx, s.db, userID, get("grade"), count); err == nil {
|
||||
message, view = "New moon claimed!", View{Section: "moons", Moon: ids[0]}
|
||||
if len(ids) > 1 {
|
||||
message = fmt.Sprintf("%d new moons claimed!", len(ids))
|
||||
}
|
||||
}
|
||||
case "sell-moon":
|
||||
var name string
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Indexes for the per-request state reads (loadState, loadPastPage) and the stake and
|
||||
-- claim checks: without them each request scanned these tables for every player.
|
||||
create index if not exists staking_source_user on staking_source (user_id);
|
||||
create index if not exists resource_well_source on resource_well (source_id);
|
||||
create index if not exists inventory_item_user on inventory_item (user_id);
|
||||
create index if not exists staking_event_user on staking_event (user_id, created_at);
|
||||
create index if not exists staking_event_well on staking_event (well_id);
|
||||
create index if not exists staking_event_tool on staking_event (inventory_item_id, created_at);
|
||||
@@ -41,6 +41,7 @@ create table staking_source (
|
||||
constraint fk_user_staking_source foreign key(user_id)
|
||||
references users(id) on delete cascade
|
||||
);
|
||||
create index staking_source_user on staking_source (user_id);
|
||||
|
||||
create table resource_well (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
@@ -52,6 +53,7 @@ create table resource_well (
|
||||
constraint fk_rid_resource_well foreign key(resource_id)
|
||||
references resource(id)
|
||||
);
|
||||
create index resource_well_source on resource_well (source_id);
|
||||
|
||||
create table inventory_item (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
@@ -68,6 +70,7 @@ create table inventory_item (
|
||||
constraint fk_store_item_inventory_item foreign key(store_item_id)
|
||||
references store_item(id) on delete cascade
|
||||
);
|
||||
create index inventory_item_user on inventory_item (user_id);
|
||||
|
||||
create table upgrade_event (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
@@ -95,6 +98,9 @@ create table staking_event (
|
||||
constraint fk_iiid_staking_event foreign key(inventory_item_id)
|
||||
references inventory_item(id) on delete cascade
|
||||
);
|
||||
create index staking_event_user on staking_event (user_id, created_at);
|
||||
create index staking_event_well on staking_event (well_id);
|
||||
create index staking_event_tool on staking_event (inventory_item_id, created_at);
|
||||
|
||||
create table claim_event (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
|
||||
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
|
||||
RateFrom float64 // credits per hour (creditsPerHour) now and after the next level
|
||||
RateTo float64
|
||||
Cost float64 // the next level's price plus its parts at sell value
|
||||
Ladder []TrackStep // every level, the other track as it is
|
||||
Cost float64 // the next level's price plus its parts at sell value
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// trackRows are the tool's two tracks with their next levels and ladders.
|
||||
// trackRows are the tool's two tracks with their next levels (ladders: trackLadder).
|
||||
func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
|
||||
var rows []TrackRow
|
||||
lv := t.Levels()
|
||||
@@ -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.Value, r.RateFrom = s.trackValue(item, lv, kind)
|
||||
for n := 0; n <= r.Max; n++ {
|
||||
st := TrackStep{Level: n, Current: n == r.Level, Next: n == r.Level+1}
|
||||
st.Value, st.Rate = s.trackValue(item, lv.With(kind, n), kind)
|
||||
if l := item.TrackLevel(kind, n); l != nil {
|
||||
st.Price, st.Parts = l.Price, s.partRows(l.Parts)
|
||||
}
|
||||
r.Ladder = append(r.Ladder, st)
|
||||
}
|
||||
if next := item.TrackLevel(kind, r.Level+1); next != nil {
|
||||
r.Next = next
|
||||
r.NextValue, r.RateTo = s.trackValue(item, lv.With(kind, r.Level+1), kind)
|
||||
@@ -328,6 +319,27 @@ func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
|
||||
return rows
|
||||
}
|
||||
|
||||
// trackLadder is every level of the item's track (by kind) at the levels lv, the other
|
||||
// track as it is: the same for every tool of that item at those levels (the API sends it
|
||||
// once per item and levels, api_sections.go ladderKey).
|
||||
func (s *State) trackLadder(item *StoreItem, lv Levels, kind string) []TrackStep {
|
||||
var out []TrackStep
|
||||
tr := item.Track(kind)
|
||||
if tr == nil {
|
||||
return out
|
||||
}
|
||||
level := lv.Of(kind)
|
||||
for n := 0; n <= len(tr.Levels); n++ {
|
||||
st := TrackStep{Level: n, Current: n == level, Next: n == level+1}
|
||||
st.Value, st.Rate = s.trackValue(item, lv.With(kind, n), kind)
|
||||
if l := item.TrackLevel(kind, n); l != nil {
|
||||
st.Price, st.Parts = l.Price, s.partRows(l.Parts)
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// upgradedText names what a tool's latest upgrade on the track gave, for the toast.
|
||||
func (s *State) upgradedText(t Tool, kind string) string {
|
||||
item := s.StoreItem(t.StoreItemID)
|
||||
|
||||
+9
-16
@@ -626,16 +626,14 @@ func (s *State) page(v View) *Page {
|
||||
for _, m := range s.Moons {
|
||||
row := MoonListRow{Moon: m, Selected: m.ID == v.Moon, Over: s.wellsOver(&m)}
|
||||
var pending []time.Time
|
||||
for _, st := range s.Stakes {
|
||||
if st.MoonID == m.ID {
|
||||
row.Drills++
|
||||
if st.Remaining(s.Now) == 0 {
|
||||
row.Ready++
|
||||
} else {
|
||||
pending = append(pending, st.End())
|
||||
if p.NextEnd.IsZero() || st.End().Before(p.NextEnd) {
|
||||
p.NextEnd = st.End()
|
||||
}
|
||||
for _, st := range s.stakesOn(m.ID) {
|
||||
row.Drills++
|
||||
if st.Remaining(s.Now) == 0 {
|
||||
row.Ready++
|
||||
} else {
|
||||
pending = append(pending, st.End())
|
||||
if p.NextEnd.IsZero() || st.End().Before(p.NextEnd) {
|
||||
p.NextEnd = st.End()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -998,12 +996,7 @@ func (s *State) project(m *Moon) projection {
|
||||
n := len(m.Wells)
|
||||
p := projection{Take: map[string][]float64{}, Gets: map[string]float64{}, Demand: make([]float64, n),
|
||||
Remain: m.supply(), Claims: make([]int, n), DryAfter: make([]int, n), DryAt: make([]time.Time, n)}
|
||||
var stakes []Stake
|
||||
for _, st := range s.Stakes {
|
||||
if st.MoonID == m.ID {
|
||||
stakes = append(stakes, st)
|
||||
}
|
||||
}
|
||||
stakes := slices.Clone(s.stakesOn(m.ID))
|
||||
sort.SliceStable(stakes, func(i, j int) bool { return stakes[i].Start.Before(stakes[j].Start) })
|
||||
current := m.supply()
|
||||
last := make([]time.Time, n) // latest end among the drills that drew from each well so far
|
||||
|
||||
@@ -138,8 +138,8 @@
|
||||
--glow-k: 1;
|
||||
--tglow-k: 1;
|
||||
--swatch: none; /* filter on resource and item color swatches */
|
||||
--sans: "Rajdhani", system-ui, sans-serif;
|
||||
--mono: "Oxanium", ui-monospace, monospace;
|
||||
--sans: system-ui, sans-serif;
|
||||
--mono: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Light: pale cool-grey paper, deep navy ink, the same hues darkened to read on it. Glows
|
||||
@@ -216,7 +216,7 @@
|
||||
html,
|
||||
body {
|
||||
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);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
@@ -346,34 +346,11 @@ h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Panel with corner brackets. */
|
||||
/* Panel. */
|
||||
.frame {
|
||||
position: relative;
|
||||
border: 1px solid var(--edge);
|
||||
background: linear-gradient(180deg, rgb(var(--hull-rgb) / 0.55), rgb(var(--deep-rgb) / 0.55));
|
||||
}
|
||||
|
||||
.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;
|
||||
background: rgb(var(--hull-rgb) / 0.4);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- controls */
|
||||
@@ -622,27 +599,13 @@ h3 {
|
||||
margin: auto;
|
||||
padding: 24px;
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
.dialog::backdrop {
|
||||
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 {
|
||||
@@ -673,105 +636,6 @@ h3 {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
@@ -1005,7 +869,6 @@ h3 {
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgb(var(--bg-rgb) / 0.92);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.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). */
|
||||
.ch-fig {
|
||||
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,
|
||||
// economy.js, moons-admin.js).
|
||||
//
|
||||
// Dialogs (.dialog, a <dialog> holding a <form method="dialog">) play a closing animation
|
||||
// (CSS: .dialog.is-closing) and close on Escape, a backdrop click or a form button.
|
||||
|
||||
const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
// Dialogs (.dialog, a <dialog> holding a <form method="dialog">) close on Escape, a backdrop
|
||||
// click or a form button.
|
||||
|
||||
// onClose(value) runs when the player closes the dialog (the game page plays a sound).
|
||||
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.
|
||||
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);
|
||||
const done = () => {
|
||||
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);
|
||||
dialog.close(value);
|
||||
}
|
||||
|
||||
// Asks a question in the confirm dialog (#confirm); resolves true for OK.
|
||||
|
||||
@@ -29,8 +29,6 @@
|
||||
});
|
||||
})();
|
||||
</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">
|
||||
{{end}}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
rendered here (economy.go, sim.go) from the draft. */}}
|
||||
|
||||
{{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>
|
||||
</head>
|
||||
<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>
|
||||
|
||||
<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="#resources" data-tab="resources">Resources</a>
|
||||
<a href="#refined" data-tab="refined">Refined</a>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
{{define "liveops"}}{{template "head"}}
|
||||
<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}}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
|
||||
{{define "players"}}{{template "head"}}
|
||||
<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>
|
||||
<body>
|
||||
{{if .Allowed}}
|
||||
@@ -83,8 +81,6 @@
|
||||
|
||||
{{define "player"}}{{template "head"}}
|
||||
<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}}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in new issue
Block a user