Compare commits

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

No files matched your search

+5
View File
@@ -36,6 +36,11 @@ banned players left out).
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
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.
+5 -1
View File
@@ -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
+16 -5
View File
@@ -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
+97 -4
View File
@@ -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
+11 -2
View File
@@ -247,12 +247,16 @@ end
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
-- 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
+7 -1
View File
@@ -96,7 +96,13 @@ function M.register(id, node, kind, label, zone, no_focus)
if not M.enabled or not id then
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
+80 -13
View File
@@ -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)
@@ -474,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
+2 -2
View File
@@ -146,9 +146,9 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
if g.autoplay then
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
+25 -10
View File
@@ -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)
+4 -2
View File
@@ -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).
+69 -21
View File
@@ -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
@@ -459,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),
@@ -479,26 +509,44 @@ function M.build(self, root, px, py, w, h, H)
#eq.tools > 0 and header(c, geo) or nil,
#eq.tools > 0 and space(0, 6) or nil,
}) })
if #eq.tools > 0 then
if #eq.tools == 0 then
return
end
ui.rect(c, x, y, inner, 1, "line_hi")
y = y + 1
end
local seen, rh = {}, row_h(open, geo.wide)
-- Each copy's number among tools of its type (in the table's order).
local seen, copies = {}, {}
for _, t in ipairs(eq.tools) do
seen[t.storeItemId] = (seen[t.storeItemId] or 0) + 1
local copy = seen[t.storeItemId]
local conf = sell_confirm(st, t)
local moon = t.stake and find(st.moons, "id", t.stake.moonId)
-- (kept while what it shows stays the same: modules/blocks)
local sig = blocks.sig(x, y, inner, t, copy, conf, open, moon and moon.name or false, st.refined, st.credits,
st.resources, st.catalogVersion, self.inflight, self.view, self.ask and self.ask.id or false)
local ry = y
blocks.keep(self, "eq:tool:" .. tostring(t.id), sig, c, function(r)
fg.build(flex.solve(tool_row(self, H, c, st, t, copy, geo, inner, open, conf), x, ry), r)
end)
y = y + rh
copies[t.id] = seen[t.storeItemId]
end
area:finish(y + py)
local rh = row_h(open, geo.wide)
local rows = {} -- slot node -> its row's block
local l
l = list.new(self, c, x, y, inner, math.max(rh, h - 4 - y - py), {
key = "equipment", row_h = rh,
focus_id = function(t) return "drill:" .. t.id end,
create = function(t)
local slot = ui.rect(l.content, 0, 0, inner, rh, "none")
local copy, conf = copies[t.id], sell_confirm(st, t)
local b, kept = blocks.open(self, "eq:tool:" .. tostring(t.id), row_sig(self, st, t, copy, conf, inner, open), slot)
if not kept then
fg.build(flex.solve(tool_row(self, H, b.root, st, t, copy, geo, inner, open, conf), 0, 0), b.root)
end
blocks.close(self, b)
rows[slot] = b
return slot
end,
-- A row scrolled out goes with its block (its buttons' Druid components too).
on_remove = function(slot)
local b = rows[slot]
rows[slot] = nil
if b then
blocks.drop(self, b)
end
end,
})
l:set_data(eq.tools)
end
---------------------------------------------------------------- drill panel (aside)
@@ -529,7 +577,7 @@ local TIER_HEAD = { font = "sans_semi", px = 10, color = "muted", tracking = 0.1
-- A track's ladder: a grid (Level, value, price and parts at 0, 16, 40 % of the row's
-- 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
@@ -544,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 = "—"
@@ -668,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.
+29
View File
@@ -273,6 +273,35 @@ function M.grade(g)
return st.moonGrade
end
-- What the rows show (widgets/moon_row: name, grade, rating, status, warning, ready
-- badge, selection), both lists: the moon list's block signature has this instead of the
-- moons themselves, so a change the rows don't show (a well's projection, a drill's end)
-- keeps the list.
function M.rows_sig(self)
local st, v = self.state, self.view
local out, n = {}, 0
for _, list in ipairs({ st.moons, st.past }) do
for _, m in ipairs(list) do
local dry = not m.finished and #(m.wells or {}) > 0
if dry then
for _, w in ipairs(m.wells) do
if w.supply > 0 then
dry = false
break
end
end
end
n = n + 1
out[n] = { m.id, m.name, m.grade or false, m.rating or false, m.finished or false, m.mined or false,
m.finishedOn or false, m.finishedAt or false, dry, m.drills or 0, m.over or false,
not m.finished and ready.drills(self, m.id) or m.ready or 0, (m.selected and v.section == "moons") or false }
end
n = n + 1
out[n] = "|"
end
return blocks.sig(out)
end
function M.build(self, H, parent, x, y, w, h)
local st = self.state
local v = self.view
+318 -151
View File
@@ -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,44 +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)
-- (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 ok,
edge = not ok and "line" or nil, fill = not ok and { "cyan", 0 } or nil })
local title, sub, sub_color
local v = { ok = ok, explicit = explicit or false, key = grp and grp.key or false,
name = grp and grp.tool.name or false, tool = grp and grp.tool.id or false }
if #groups == 0 then
title, sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
v.title, v.sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
elseif not grp then
title, sub = "No room here", "The drills on it take all it has"
v.title, v.sub = "No room here", "The drills on it take all it has"
elseif not ok then
title, sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
v.title, v.sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
else
local t = grp.tool
local reach = reach_short(st, t.reach)
title = "+ Deploy " .. M.short_name(t)
sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
v.title = "+ Deploy " .. M.short_name(t)
v.sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
if here < amount - EPS then
sub, sub_color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
v.sub, v.color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
end
end
return v
end
-- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a
-- cycle gets here, or why it can't (no idle drills, no room). v: deploy_view. It sends
-- the group's first idle copy at the click (a kept slot outlives the drill it was built
-- with); its test id is the well's: act:stake?target=<well>.
local function deploy_slot(g, well, v, mk, H)
return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
part(g, "mo:deploy:" .. well.id, blocks.sig(w, v, mk), p0, x0, y0, function(p)
local x, y = 0, 0
-- (off: a plain line and no fill, so it reads as greyed beside a live slot)
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not v.ok,
edge = not v.ok and "line" or nil, fill = not v.ok and { "cyan", 0 } or nil })
local tree = col({ width = w, height = SLOT_H, padding = { 0, 10 }, justify = "center", align = "center", gap = 2 }, {
fit(b, title, { font = "sans_semi", px = 13, color = ok and "cyan" or "muted", tracking = 0.06, upper = true }),
fit(b, sub, { font = "sans", px = 12, color = sub_color or "muted" }),
fit(b, v.title, { font = "sans_semi", px = 13, color = v.ok and "cyan" or "muted", tracking = 0.06, upper = true }),
fit(b, v.sub, { font = "sans", px = 12, color = v.color or "muted" }),
})
fg.build(flex.solve(tree, 0, 0), b)
if ok then
local params = { tool = grp.tool.id, target = well.id }
H.action(g, b, bn, "stake", params)
tooltip.add(b, "Deploy " .. (grp.tool.name or "a drill") .. (explicit and "" or " (the smallest idle drill with room here)"))
if v.ok then
local function params()
for _, grp in ipairs(M.idle_groups(g.state)) do
if grp.key == v.key then
return { tool = grp.tool.id, target = well.id }
end
end
return { tool = v.tool, target = well.id } -- (gone since: the server says why)
end
H.action(g, b, bn, "stake", params, nil, nil, "act:stake?target=" .. well.id)
tooltip.add(b, "Deploy " .. (v.name or "a drill") .. (v.explicit and "" or " (the smallest idle drill with room here)"))
end
end)
end)
end
-- What a drill's slot shows besides the drill d: dm = { name, due, reach } (drill_models).
-- A drill's slot (SLOT_H tall; slides in when it was just deployed): its short name and
-- time left over its timer bar and what it gets; done (ready, or its end passed on our
-- clock): gold, "+X ready", Claim (scope: claim-well or claim-wide) and, once unlocked,
@@ -347,11 +399,13 @@ end
-- the plain action ids). wait: the group's next drill to finish; where its Claim and
-- Redeploy will show is registered as "wait:claim?..." (like H.claim_wait's waiting
-- button; not focusable), for the test driver.
local function drill_slot(g, st, d, scope, can_redeploy, n, wait, H, enters)
local tool = find(st.tools, d.toolId)
local due = d.ready or (d["end"] or math.huge) <= H.now(g)
local reach = reach_short(st, d.reach)
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
local function drill_slot(g, st, d, dm, scope, can_redeploy, n, wait, mk, H, enters)
local due = dm.due
local reach = dm.reach or nil
return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
-- (a part of its own: a card that changes keeps the slots that didn't)
part(g, "mo:slot:" .. d.toolId, blocks.sig(w, d, dm, scope, can_redeploy, n, wait, mk), p0, x0, y0, function(p)
local x, y = 0, 0
local slot = ui.rect(p, x, y, w, SLOT_H, "none")
local body = ui.rect(slot, 0, 0, w, SLOT_H, due and "ready" or "cyan", due and 0.08 or 0.04)
ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil)
@@ -410,7 +464,7 @@ local function drill_slot(g, st, d, scope, can_redeploy, n, wait, H, enters)
end
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
row({ height = 16, align = "center" }, {
fit(body, M.short_name(tool), NAME13),
fit(body, dm.name, NAME13),
reach and space(5) or nil, reach and fit(body, "· " .. reach, S12) or nil,
fg.fill(), space(6), right,
}),
@@ -441,11 +495,36 @@ local function drill_slot(g, st, d, scope, can_redeploy, n, wait, H, enters)
row_c.enter(g, "drill:" .. d.toolId, body, w, SLOT_H, { base = gui.get_color(body) })
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
@@ -456,21 +535,27 @@ local function idle_slot(g, st, well, H)
local who = #names > 0 and (table.concat(names, ", ") .. (n > #names and (" +" .. (n - #names)) or "")) or ui.plural(n, "drill")
-- (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)
@@ -490,40 +575,53 @@ local function well_note(c, well)
end
end
-- The drill of a list that finishes next (not done yet), or nil.
local function next_drill(g, drills)
local now, pick = g.h.now(g), nil
for _, d in ipairs(drills or {}) do
if not d.ready and d["end"] and d["end"] > now and (not pick or d["end"] < pick["end"]) then
pick = d
end
end
return pick
end
-- One well's card (a grid cell; its node selects the well when clicked).
local function well_card(g, c, st, moon, well, max, groups, H, enters)
-- What a well's card shows (its block's signature with its rect): the well, its deploy
-- slot, its drills' slots, its idle slot. slots: how many (card_h).
local function card_model(g, st, well, max, groups, H)
local dry = well.supply <= 0
local selected = well.id == g.view.well
local scope = { ["claim-well"] = well.id }
local slots = {}
if not dry then
slots[#slots + 1] = deploy_slot(g, st, well, groups, H)
end
local n, next_d = 0, next_drill(g, well.drills)
-- Tapped out once the ready drills are claimed (nothing left, or what is left is the
-- other drills' on it, the server's `remain`): Claim only, no Redeploy.
local tapped = well.supply - ready.amount(g, well, well.cap) <= 1e-9 or (well.remain or 1) <= 1e-9
for _, d in ipairs(well.drills or {}) do
local due = d.ready or (d["end"] or math.huge) <= H.now(g)
n = n + (due and 1 or 0)
slots[#slots + 1] = drill_slot(g, st, d, scope, not dry and not tapped and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters)
local m = {
well = well, max = max, dry = dry, selected = well.id == g.view.well,
redeploy = (not dry and not tapped and st.unlocked.redeploy) and true or false,
deploy = not dry and deploy_view(g, st, well, groups) or false,
drills = drill_models(g, st, well.drills, H),
idle = (well.redrill or 0) > 0 and idle_view(st, well) or false,
}
m.slots = (dry and 0 or 1) + #m.drills + (m.idle and 1 or 0)
local scope = "claim?claim-well=" .. well.id
m.marks = marks(g, { scope, scope .. "&redeploy=1", "stake?target=" .. well.id, "redrill?redrill-well=" .. well.id })
return m
end
if (well.redrill or 0) > 0 then
slots[#slots + 1] = idle_slot(g, st, well, H)
-- A card's height for its slots (its tree's, below: the head lines, then the slots).
local CARD_HEAD = 18 + 7 + 6 + 4 + 16 + 8
local function card_h(slots)
return CARD_PAD * 2 + CARD_HEAD + (slots > 0 and (slots * SLOT_H + (slots - 1) * SLOT_GAP) or 0)
end
-- One well's card, w x h at the origin of c (its node selects the well when clicked).
-- m: card_model.
local function well_card(g, c, st, moon, m, w, h, H)
local well = m.well
local dry = m.dry
local scope = { ["claim-well"] = well.id }
local enters = {}
local slots = {}
if m.deploy then
slots[#slots + 1] = deploy_slot(g, well, m.deploy, m.marks, H)
end
for i, d in ipairs(well.drills or {}) do
local dm = m.drills[i]
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
end
if m.idle then
slots[#slots + 1] = idle_slot(g, well, m.idle, H)
end
local sup = fit(c, ui.money(well.supply), { font = "mono", px = 13, color = dry and "muted" or "text" }, { shrink = 0 })
sup.done = function(node) H.tick_num(g, node, "supply:" .. well.id, well.supply) end
local list = col({ gap = SLOT_GAP }, slots)
local kids = {
row({ height = 18, align = "center" }, {
made({ width = 8, height = 8, shrink = 0 }, function(q, x, y) ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1) end),
@@ -531,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(),
@@ -584,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 {}
@@ -620,40 +722,78 @@ local function tray(g, c, st, groups, inner, H)
end
end
end
local tiles = {}
for _, grp in ipairs(groups) do
local tiles, keys = {}, {}
for i, grp in ipairs(groups) do
local t = grp.tool
local well = target(t)
local here = well and gets_here(st, well, t) or 0
local ok = well ~= nil and well.supply > 0 and here > EPS
local picked = false
tiles[#tiles + 1] = made({ height = TILE_H }, function(p, x, y, w)
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = TILE_H, disabled = not ok })
local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg")
if t.image then
images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), t.image)
tiles[i] = { key = grp.key, name = M.short_name(t), count = grp.count, tool = t.id, tname = t.name or false, image = t.image or false,
gets = "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach_short(st, t.reach) and (" · " .. reach_short(st, t.reach)) or ""),
well = well and well.id or false, resource = well and well.resource or false,
ok = (well ~= nil and well.supply > 0 and here > EPS) and true or false }
if well then
keys[#keys + 1] = "stake?target=" .. well.id .. "&tool=" .. t.id
end
local reach = reach_short(st, t.reach)
local tree = col({ width = w, height = TILE_H, padding = { 8, 8, 8, TILE_H }, gap = 4 }, {
row({ height = 16, align = "center" }, {
fit(b, M.short_name(t), NAME13), fg.fill(), space(4),
fg.label(b, "×" .. grp.count, { font = "mono", px = 12, color = picked and "cyan" or "text_2" }, 16, { shrink = 0 }),
}),
fit(b, "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or ""), { font = "mono", px = 11, color = "muted" }),
end
return { tiles = tiles, store = st.unlocked.store and true or false, moon = g.view.moon, marks = marks(g, keys) }
end
-- The tray (inner wide, at the origin of c): a tile per group (picture, short name,
-- ×count, what a cycle gets); a tile deploys one of them. m: tray_model. Returns its
-- height.
local function tray(g, c, m, inner, H)
local head = row({ height = 16, align = "center" }, {
fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0),
#m.tiles > 0 and fit(c, "Click one to deploy it to the selected well", "small") or nil,
})
fg.build(flex.solve(tree, 0, 0), b)
local tree
if #m.tiles == 0 then
tree = col({ width = inner }, {
head, space(0, 8),
row({ height = 16, align = "center" }, {
fg.label(c, "No idle drills.", "small", 16),
m.store and space(5) or nil,
m.store and made({ width = 160, height = 16, shrink = 0 }, function(p, x, y)
H.text_link(g, p, "Buy one in the store", { font = "sans", px = 13 }, x, y + 8, { section = "store", moon = m.moon })
end) or nil,
}),
})
else
local tiles = {}
for _, tv in ipairs(m.tiles) do
local picked = false
tiles[#tiles + 1] = made({ height = TILE_H }, function(p0, x0, y0, w)
part(g, "mo:tile:" .. tv.key, blocks.sig(w, tv, m.marks), p0, x0, y0, function(p)
local x, y = 0, 0
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = TILE_H, disabled = not tv.ok })
local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg")
if tv.image then
images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), tv.image)
end
local tt = col({ width = w, height = TILE_H, padding = { 8, 8, 8, TILE_H }, gap = 4 }, {
row({ height = 16, align = "center" }, {
fit(b, tv.name, NAME13), fg.fill(), space(4),
fg.label(b, "×" .. tv.count, { font = "mono", px = 12, color = picked and "cyan" or "text_2" }, 16, { shrink = 0 }),
}),
fit(b, tv.gets, { font = "mono", px = 11, color = "muted" }),
})
fg.build(flex.solve(tt, 0, 0), b)
if picked then
ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9)
end
if ok then
tooltip.add(b, "Deploy " .. (t.name or "a drill") .. " to the " .. well.resource .. " well")
H.action(g, b, bn, "stake", { tool = t.id, target = well.id })
if tv.ok then
tooltip.add(b, "Deploy " .. (tv.tname or "a drill") .. " to the " .. tv.resource .. " well")
H.action(g, b, bn, "stake", { tool = tv.tool, target = tv.well })
else
tooltip.add(b, (t.name or "Drill") .. (well and (" · no room on the " .. well.resource .. " well") or " · no room on this moon"))
tooltip.add(b, (tv.tname or "Drill") .. (tv.well and (" · no room on the " .. tv.resource .. " well") or " · no room on this moon"))
end
end)
end)
end
return col({ width = inner }, { head, space(0, 8), flex.grid(inner, TILE_MIN, 8, tiles) })
tree = col({ width = inner }, { head, space(0, 8), flex.grid(inner, TILE_MIN, 8, tiles) })
end
fg.build(flex.solve(tree, 0, 0), c)
return tree.h
end
@@ -685,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
@@ -715,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 })
@@ -729,9 +865,31 @@ local function moon_view(g, c, px, py, inner, min_h, H)
done = n_ready > 0 and ("Claim " .. ui.plural(n_ready, "drill")) or nil })
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
@@ -741,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
+7
View File
@@ -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"
+11 -4
View File
@@ -25,7 +25,9 @@ every command below from this directory (`server/`).
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
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
@@ -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/`;
+2 -1
View File
@@ -101,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
+6
View File
@@ -688,6 +688,12 @@ func (s *server) apiPage(ctx context.Context, userID string, v View, get func(st
// apiPageFor builds the page for a resolved view, with what the server loads for it.
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
+13 -9
View File
@@ -166,14 +166,15 @@ short code, listed with each field, and the web's text next to it.
```
{tools: [{id, storeItemId, name, code, image, yieldLevel, cycleLevel, count, idle, reach, cycleSecs, yield,
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}}
+31 -8
View File
@@ -9,6 +9,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
)
@@ -304,6 +305,9 @@ type APILiveEvent struct {
type APIEquipment struct {
Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped
// The upgrade ladders by ladderKey (store item and levels: every tool with those has
// the same), each track's by kind; a tool row names its own (ladderKey).
Ladders map[string]map[string][]APILadderStep `json:"ladders"`
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
}
@@ -323,6 +327,7 @@ type APIToolRow struct {
Selected bool `json:"selected"`
Stake *APIStake `json:"stake"` // the shown copy's drill
Tracks []APITrack `json:"tracks"` // yield, then cycle
LadderKey string `json:"ladderKey"` // its tracks' ladders in equipment.ladders
Resale float64 `json:"resale"`
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
NoSaleText string `json:"noSaleText"`
@@ -338,7 +343,6 @@ type APITrack struct {
Max int `json:"max"`
Value float64 `json:"value"`
Next *APINextLevel `json:"next"` // null at the top level
Ladder []APILadderStep `json:"ladder"`
}
type APINextLevel struct {
@@ -399,11 +403,7 @@ func apiParts(rows []PartRow) []APIPart {
func apiTracks(s *State, rows []TrackRow) []APITrack {
out := []APITrack{}
for _, r := range rows {
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value, Ladder: []APILadderStep{}}
for _, st := range r.Ladder {
t.Ladder = append(t.Ladder, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
}
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value}
if n := r.Next; n != nil {
next := &APINextLevel{Level: r.Level + 1, Value: r.NextValue, Label: r.Label(), Price: n.Price,
Parts: apiParts(r.Parts), CanAfford: r.CanAfford, Short: max(0, n.Price-s.Balance),
@@ -427,15 +427,38 @@ func apiTracks(s *State, rows []TrackRow) []APITrack {
return out
}
// ladderKey names a tool's ladders: "<store item>:<yield level>:<cycle level>".
func ladderKey(storeItemID int, lv Levels) string {
return fmt.Sprintf("%d:%d:%d", storeItemID, lv.Yield, lv.Cycle)
}
// apiLadders are the item's ladders at the levels, by kind (the tracks it has).
func apiLadders(s *State, item *StoreItem, lv Levels, tracks []TrackRow) map[string][]APILadderStep {
out := map[string][]APILadderStep{}
for _, r := range tracks {
steps := []APILadderStep{}
for _, st := range s.trackLadder(item, lv, r.Kind) {
steps = append(steps, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
}
out[r.Kind] = steps
}
return out
}
func apiEquipment(p *Page) *APIEquipment {
s := p.S
b := s.Bonus()
e := &APIEquipment{Tools: []APIToolRow{}}
e := &APIEquipment{Tools: []APIToolRow{}, Ladders: map[string]map[string][]APILadderStep{}}
for _, row := range p.Equipment {
key := ladderKey(row.StoreItemID, row.Levels())
if _, ok := e.Ladders[key]; !ok {
e.Ladders[key] = apiLadders(s, row.Item, row.Levels(), row.Tracks)
}
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
Image: itemImages + row.Item.Image, YieldLevel: row.YieldLevel, CycleLevel: row.CycleLevel,
Count: row.Count, Idle: row.Idle, Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield,
Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), Resale: row.Item.ToolResale(row.Levels()),
Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), LadderKey: key, Resale: row.Item.ToolResale(row.Levels()),
NoSale: s.noSale(row), NoSaleText: noSaleText[s.noSale(row)], Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}}
if row.Stake != nil {
st := apiStake(*row.Stake, false)
+1 -1
View File
@@ -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)
}
}
+639
View File
@@ -0,0 +1,639 @@
package main
// Claim-all against a real database: TestClaimGolden plays fixed scenarios (one-well and
// multi-well drills on shared wells, wells emptied mid-claim, redeploy on and off, every
// scope, reach 1, 3 and 5) and compares what they leave in the database with
// testdata/claim_golden.txt; TestClaimAllPerf times claim-all and counts its statements.
// Both need MM_TEST_DATABASE, a migrated database of their own (scripts/wt makes one:
// mm_<task>); without it they are skipped. MM_CLAIM_GOLDEN_WRITE=1 rewrites the golden file.
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"os"
"slices"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// countTracer counts the statements a pool sends and keeps the slowest.
type countTracer struct {
n atomic.Int64
mu sync.Mutex
slow []string
}
type traceStart struct {
sql string
at time.Time
}
func (c *countTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, d pgx.TraceQueryStartData) context.Context {
c.n.Add(1)
return context.WithValue(ctx, c, traceStart{d.SQL, time.Now()})
}
func (c *countTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryEndData) {
if s, ok := ctx.Value(c).(traceStart); ok {
if d := time.Since(s.at); d > 10*time.Millisecond {
c.mu.Lock()
c.slow = append(c.slow, fmt.Sprintf("%v %s", d.Round(time.Millisecond), strings.Join(strings.Fields(s.sql), " ")))
c.mu.Unlock()
}
}
}
func (c *countTracer) TraceBatchStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceBatchStartData) context.Context {
return ctx
}
func (c *countTracer) TraceBatchQuery(context.Context, *pgx.Conn, pgx.TraceBatchQueryData) {
c.n.Add(1)
}
func (c *countTracer) TraceBatchEnd(context.Context, *pgx.Conn, pgx.TraceBatchEndData) {}
func testDB(t *testing.T) (*pgxpool.Pool, *countTracer) {
t.Helper()
name := os.Getenv("MM_TEST_DATABASE")
if name == "" {
t.Skip("MM_TEST_DATABASE not set")
}
if name == "moonminers" {
t.Fatal("MM_TEST_DATABASE must not be the game's database")
}
if !loadDotEnv(".env") {
loadDotEnv("../.env")
}
os.Setenv("DB_DATABASE", name)
pc, err := pgxpool.ParseConfig(appDSN())
if err != nil {
t.Fatal(err)
}
tr := &countTracer{}
pc.ConnConfig.Tracer = tr
db, err := pgxpool.NewWithConfig(context.Background(), pc)
if err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
return db, tr
}
// fx builds players straight in the database and names every row it makes (labels), so
// what a claim leaves can be compared across runs.
type fx struct {
t *testing.T
ctx context.Context
db *pgxpool.Pool
run uint32 // the first part of the ids the fixture makes (orders them as made)
ids int
label map[string]string // id → label
res map[string]int // resource name → id
resN []string // resource names, sorted
clock int // seconds after base for the next stake
action int
}
var fxBase = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
func newFx(t *testing.T, db *pgxpool.Pool) *fx {
f := &fx{t: t, ctx: context.Background(), db: db, label: map[string]string{}, res: map[string]int{},
run: rand.Uint32()}
rows, err := db.Query(f.ctx, "select id, name from resource order by name")
f.must(err)
var id int
var n string
_, err = pgx.ForEachRow(rows, []any{&id, &n}, func() error {
f.res[n] = id
f.resN = append(f.resN, n)
return nil
})
f.must(err)
if len(f.resN) < 5 {
t.Fatalf("resources: %v", f.resN)
}
return f
}
func (f *fx) must(err error) {
f.t.Helper()
if err != nil {
f.t.Fatal(err)
}
}
func (f *fx) id(q string, args ...any) string {
f.t.Helper()
var id string
f.must(f.db.QueryRow(f.ctx, q, args...).Scan(&id))
return id
}
func (f *fx) user(label string, onboarding []string, advisors ...string) string {
if onboarding == nil {
onboarding = []string{}
}
u := f.id("insert into users(name, onboarding) values ($1, $2) returning id::text", label, onboarding)
f.label[u] = label
f.t.Cleanup(func() {
f.db.Exec(f.ctx, "delete from event_log where user_id = $1", u)
f.db.Exec(f.ctx, "delete from users where id = $1", u)
})
_, err := f.db.Exec(f.ctx, "insert into bank_account(user_id, balance) values ($1, 1000)", u)
f.must(err)
_, err = f.db.Exec(f.ctx, `insert into resource_account(resource_id, user_id, balance)
select id, $1, 0.1 * id from resource`, u)
f.must(err)
for i, a := range advisors {
_, err = f.db.Exec(f.ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", u, a, i+1)
f.must(err)
}
return u
}
// moon makes a moon with wells of the given resources (indexes into resN) and supplies;
// the wells are labelled <moon>.<i>.
func (f *fx) moon(user, label string, res []int, supply []float64) []string {
m := f.id(`insert into staking_source(name, description, user_id, address, grade)
values ($1, '', $2, '', 'C') returning id::text`, label, user)
f.label[m] = label
var wells []string
for i, r := range res {
f.ids++
w := f.id("insert into resource_well(id, resource_id, source_id, supply) values ($1, $2, $3, $4) returning id::text",
fmt.Sprintf("%08x-0000-4000-8000-%012x", f.run, f.ids), f.res[f.resN[r%len(f.resN)]], m, supply[i])
f.label[w] = fmt.Sprintf("%s.%d", label, i)
wells = append(wells, w)
}
return wells
}
func (f *fx) tool(user, label string, item, yl, cl int) string {
id := f.id("insert into inventory_item(user_id, store_item_id, yield_level, cycle_level) values ($1, $2, $3, $4) returning id::text",
user, item, yl, cl)
f.label[id] = label
return id
}
// stake puts a stake in: ready (started at the next tick of the fixture clock, long ago)
// or still running.
func (f *fx) stake(user, label, tool, well string, amount float64, reach int, ready bool) string {
f.clock++
at, secs := fxBase.Add(time.Duration(f.clock)*time.Second), 30.0
if !ready {
at, secs = time.Now().Add(time.Duration(f.clock)*time.Second), 1e8
}
id := f.id(`insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
values ($1, $2, $3, $4, $5, $6, $7) returning id::text`, user, well, tool, secs, amount, reach, at)
f.label[id] = label
return id
}
// finish makes every running stake of the player ready, in the order of their tools' labels
// (deterministic, unlike the random ids that order stakes started together).
func (f *fx) finish(user string) {
rows, err := f.db.Query(f.ctx, `select id::text, inventory_item_id::text from staking_event
where user_id = $1 and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`, user)
f.must(err)
type st struct{ ID, Tool string }
stakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[st])
f.must(err)
sort.Slice(stakes, func(i, j int) bool { return f.label[stakes[i].Tool] < f.label[stakes[j].Tool] })
for _, s := range stakes {
f.clock++
_, err := f.db.Exec(f.ctx, "update staking_event set created_at = $2, duration_in_secs = least(duration_in_secs, 30) where id = $1",
s.ID, fxBase.Add(time.Duration(f.clock)*time.Second))
f.must(err)
}
}
func fnum(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
// claim runs claimReady and writes the result and the player's whole state to out.
func (f *fx) claim(out *strings.Builder, user string, scope ClaimScope, what string) {
f.action++
var lastLog int64
f.must(f.db.QueryRow(f.ctx, "select coalesce(max(id), 0) from event_log where user_id = $1", user).Scan(&lastLog))
res, err := claimReady(f.ctx, f.db, user, scope)
fmt.Fprintf(out, "== %s %s: %s\n", f.label[user], what, f.lbl(scope.MoonID+scope.WellID))
if err != nil {
fmt.Fprintf(out, "error: %v\n", err)
} else {
var fin []string
for _, m := range res.Finished {
fin = append(fin, f.label[m.ID]+"="+m.Name)
}
fmt.Fprintf(out, "claimed %d redeployed %d total %s stopped %d finished %v\n",
res.Claimed, res.Redeployed, fnum(res.Total), res.Stopped, fin)
}
f.dump(out, user, lastLog)
}
func (f *fx) lbl(id string) string {
if l, ok := f.label[id]; ok {
return l
}
return id
}
func (f *fx) dump(out *strings.Builder, user string, lastLog int64) {
q := func(sql string, args ...any) pgx.Rows {
rows, err := f.db.Query(f.ctx, sql, args...)
f.must(err)
return rows
}
// Name the stakes this action made: tool@action.
rows := q("select id::text, inventory_item_id::text from staking_event where user_id = $1", user)
var id, tool string
_, err := pgx.ForEachRow(rows, []any{&id, &tool}, func() error {
if _, ok := f.label[id]; !ok {
f.label[id] = fmt.Sprintf("%s@%d", f.label[tool], f.action)
}
return nil
})
f.must(err)
var lines []string
rows = q(`select resource_well.id::text, supply from resource_well
join staking_source on staking_source.id = source_id where user_id = $1`, user)
var supply float64
_, err = pgx.ForEachRow(rows, []any{&id, &supply}, func() error {
lines = append(lines, fmt.Sprintf("well %s %s", f.lbl(id), fnum(supply)))
return nil
})
f.must(err)
sort.Strings(lines)
out.WriteString(strings.Join(lines, "\n") + "\n")
lines = nil
rows = q(`select resource.name, balance from resource_account join resource on resource.id = resource_id
where user_id = $1`, user)
var name string
var bal float64
_, err = pgx.ForEachRow(rows, []any{&name, &bal}, func() error {
lines = append(lines, fmt.Sprintf("balance %s %s", name, fnum(bal)))
return nil
})
f.must(err)
sort.Strings(lines)
out.WriteString(strings.Join(lines, "\n") + "\n")
var flags []string
f.must(f.db.QueryRow(f.ctx, "select onboarding from users where id = $1", user).Scan(&flags))
slices.Sort(flags)
fmt.Fprintf(out, "onboarding %v\n", flags)
lines = nil
rows = q(`select staking_event.id::text, well_id::text, inventory_item_id::text, duration_in_secs, stake_amount,
staking_event.reach, claim_event.claim_amount,
coalesce((select string_agg(cw.well_id::text || '=' || cw.amount::text, ',') from claim_well cw
where cw.claim_event_id = claim_event.id), '')
from staking_event left join claim_event on claim_event.staking_event_id = staking_event.id
where staking_event.user_id = $1`, user)
var well string
var dur, amount float64
var reach int
var claim *float64
var cw string
_, err = pgx.ForEachRow(rows, []any{&id, &well, &tool, &dur, &amount, &reach, &claim, &cw}, func() error {
c := "running"
if claim != nil {
c = "claimed " + fnum(*claim)
}
var parts []string
for _, p := range strings.Split(cw, ",") {
if w, a, ok := strings.Cut(p, "="); ok {
parts = append(parts, f.lbl(w)+"="+a)
}
}
slices.Sort(parts)
lines = append(lines, fmt.Sprintf("stake %s well %s tool %s secs %s amount %s reach %d %s %v",
f.lbl(id), f.lbl(well), f.lbl(tool), fnum(dur), fnum(amount), reach, c, parts))
return nil
})
f.must(err)
sort.Strings(lines)
out.WriteString(strings.Join(lines, "\n") + "\n")
// The event log in order; runs of stake_stopped (ordered by start time only, ties
// at random) sorted.
lines = nil
rows = q("select kind, data::text from event_log where user_id = $1 and id > $2 order by id", user, lastLog)
var kind, data string
var run []string
flush := func() {
slices.Sort(run)
lines = append(lines, run...)
run = nil
}
_, err = pgx.ForEachRow(rows, []any{&kind, &data}, func() error {
var m map[string]any
if err := json.Unmarshal([]byte(data), &m); err != nil {
return err
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
var b strings.Builder
for _, k := range keys {
v := m[k]
if s, ok := v.(string); ok {
v = f.lbl(s)
} else if x, ok := v.(float64); ok {
v = fnum(x)
}
fmt.Fprintf(&b, " %s=%v", k, v)
}
line := "log " + kind + b.String()
if kind == "stake_stopped" {
run = append(run, line)
return nil
}
flush()
lines = append(lines, line)
return nil
})
f.must(err)
flush()
if len(lines) > 0 {
out.WriteString(strings.Join(lines, "\n") + "\n")
}
}
// withConfig runs fn with the store items' reach replaced (item id → reach).
func withReach(reach map[int]int, fn func()) {
old := cfg()
c := *old
c.StoreItems = slices.Clone(old.StoreItems)
for i := range c.StoreItems {
if r, ok := reach[c.StoreItems[i].ID]; ok {
c.StoreItems[i].Reach = r
}
}
effective.Store(&c)
defer effective.Store(old)
fn()
}
// seqStakeIDs makes new staking_event ids come from a sequence, in the order the stakes
// are made, whether the database makes them (the column default) or the server
// (newStakeID): stakes started together are ordered by id, which is otherwise random.
func seqStakeIDs(t *testing.T, db *pgxpool.Pool) {
ctx := context.Background()
admin, err := pgxpool.New(ctx, adminDSN())
if err != nil {
t.Fatal(err)
}
defer admin.Close()
const id = `('00000000-0000-4000-8000-' || lpad(to_hex(nextval('claim_test_seq')), 12, '0'))::uuid`
for _, q := range []string{
"create sequence if not exists claim_test_seq",
"grant usage, select on sequence claim_test_seq to " + pgx.Identifier{env("DB_USER", "moonminers")}.Sanitize(),
"alter table staking_event alter column id set default " + id,
} {
if _, err := admin.Exec(ctx, q); err != nil {
t.Fatal(err)
}
}
oldID := newStakeID
newStakeID = func() string {
var s string
if err := db.QueryRow(ctx, "select "+id+"::text").Scan(&s); err != nil {
panic(err)
}
return s
}
t.Cleanup(func() {
newStakeID = oldID
admin, err := pgxpool.New(ctx, adminDSN())
if err != nil {
t.Error(err)
return
}
defer admin.Close()
if _, err := admin.Exec(ctx, "alter table staking_event alter column id set default gen_random_uuid()"); err != nil {
t.Error(err)
}
})
}
func TestClaimGolden(t *testing.T) {
db, _ := testDB(t)
seqStakeIDs(t, db)
ctx := context.Background()
// Live events for the redeployed stakes' amounts and durations; removed at the end.
var evs []int
for _, e := range []struct {
kind, res string
mult float64
}{{"cycle_multiplier", "all", 1.5}, {"yield_multiplier", "Sollux", 1.25}, {"yield_multiplier", "Azurium", 0.8}} {
var id int
err := db.QueryRow(ctx, `insert into live_event(name, kind, params, starts_at, ends_at)
values ('claim test', $1, $2, now() - interval '1 day', now() + interval '1 day') returning id`,
e.kind, map[string]any{"resource": e.res, "multiplier": e.mult}).Scan(&id)
if err != nil {
t.Fatal(err)
}
evs = append(evs, id)
}
defer db.Exec(ctx, "delete from live_event where id = any($1)", evs)
var out strings.Builder
run := func(cfgName string) {
f := newFx(t, db)
claimScenarios(f, &out, cfgName)
}
run("reach5")
withReach(map[int]int{1: 1, 2: 1, 3: 3, 4: 2}, func() { run("mixed") })
withReach(map[int]int{1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1}, func() { run("reach1") })
got := out.String()
const path = "testdata/claim_golden.txt"
if os.Getenv("MM_CLAIM_GOLDEN_WRITE") == "1" {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if got != string(want) {
os.WriteFile(os.TempDir()+"/claim_golden.got", []byte(got), 0o644)
gl, wl := strings.Split(got, "\n"), strings.Split(string(want), "\n")
for i := 0; i < min(len(gl), len(wl)); i++ {
if gl[i] != wl[i] {
t.Fatalf("differs at line %d (full output in %s/claim_golden.got):\n got: %s\nwant: %s",
i+1, os.TempDir(), gl[i], wl[i])
}
}
t.Fatalf("differs in length: got %d lines, want %d", len(gl), len(wl))
}
}
// claimScenarios plays every scenario for one config; p prefixes the labels.
func claimScenarios(f *fx, out *strings.Builder, p string) {
all := ClaimScope{}
redeploy := ClaimScope{Redeploy: true}
// 1. One-well drills overdrilling a well: the third claim empties it and stops the
// fourth (ready, later in the list) and a running one; a multi-well drill on the
// emptied well; claim-all with redeploy, then the redeployed drills again.
u := f.user(p+"-overdrill", nil)
w := f.moon(u, p+"-m1", []int{3, 0, 4}, []float64{10, 50, 5})
for i, item := range []int{1, 2, 1, 3} {
f.stake(u, fmt.Sprintf("%s-s%d", p, i), f.tool(u, fmt.Sprintf("%s-t%d", p, i), item, 0, 0), w[0], 4, 1, true)
}
f.stake(u, p+"-run", f.tool(u, p+"-t4", 1, 0, 0), w[0], 3, 1, false)
f.stake(u, p+"-wide", f.tool(u, p+"-t5", 5, 1, 0), w[2], 7, 5, true)
f.stake(u, p+"-az", f.tool(u, p+"-t6", 2, 0, 1), w[1], 9, 1, true)
f.claim(out, u, redeploy, "claim-all redeploy")
f.finish(u)
f.claim(out, u, redeploy, "claim-all redeploy 2")
f.finish(u)
f.claim(out, u, all, "claim-all")
// 2. Multi-well drills on a 7-well moon (reach 5 covers part of it) and a 4-well moon,
// wells emptied by multi-well claims stopping one-well drills; a moon that finishes.
u = f.user(p+"-wide", []string{"market"}, "Ada Voss", "Rook Halvorsen")
w7 := f.moon(u, p+"-m7", []int{0, 1, 2, 3, 4, 0, 1}, []float64{3, 8, 0, 12, 2.5, 6, 1})
w4 := f.moon(u, p+"-m4", []int{4, 3, 2, 1}, []float64{2, 2, 1, 0.5})
n := 0
tl := func(item, yl, cl int) string {
n++
return f.tool(u, fmt.Sprintf("%s-w%d", p, n), item, yl, cl)
}
f.stake(u, p+"-a1", tl(4, 0, 0), w7[0], 9, 5, true)
f.stake(u, p+"-a2", tl(1, 0, 0), w7[3], 5, 1, true)
f.stake(u, p+"-a3", tl(5, 2, 1), w7[6], 6, 5, true)
f.stake(u, p+"-a4", tl(1, 1, 0), w7[1], 2, 1, true)
f.stake(u, p+"-a5", tl(3, 0, 0), w7[2], 4, 3, true)
f.stake(u, p+"-a6", tl(1, 0, 0), w7[4], 3, 1, false)
f.stake(u, p+"-a7", tl(6, 0, 0), w7[5], 20, 5, false)
f.stake(u, p+"-b1", tl(2, 0, 0), w4[0], 1.5, 1, true)
f.stake(u, p+"-b2", tl(7, 0, 0), w4[1], 3, 5, true)
f.stake(u, p+"-b3", tl(1, 0, 0), w4[3], 1, 1, true)
f.stake(u, p+"-b4", tl(4, 0, 0), w4[2], 2, 3, true)
m7 := f.moonOf(w7[0])
f.claim(out, u, ClaimScope{MoonID: m7}, "claim-moon m7")
f.claim(out, u, ClaimScope{MoonID: m7, Redeploy: true}, "claim-moon m7 redeploy")
f.claim(out, u, ClaimScope{MoonID: f.moonOf(w4[0]), Wide: true, Redeploy: true}, "claim-wide m4 redeploy")
f.claim(out, u, ClaimScope{WellID: w4[0], Redeploy: true}, "claim-well m4.0 redeploy")
f.claim(out, u, ClaimScope{WellID: w4[3]}, "claim-well m4.3")
f.finish(u)
f.claim(out, u, redeploy, "claim-all redeploy")
f.finish(u)
f.claim(out, u, redeploy, "claim-all redeploy 2")
f.finish(u)
f.claim(out, u, all, "claim-all")
f.claim(out, u, all, "claim-all nothing")
// 3. A tool with a second unclaimed stake: redeploying it fails the whole claim.
u = f.user(p+"-busy", []string{"all"})
w = f.moon(u, p+"-mb", []int{0, 1}, []float64{20, 20})
t := f.tool(u, p+"-bt", 1, 0, 0)
f.stake(u, p+"-bs1", t, w[0], 2, 1, true)
f.stake(u, p+"-bs2", t, w[1], 2, 1, false)
f.stake(u, p+"-bs3", f.tool(u, p+"-bt2", 2, 0, 0), w[1], 2, 1, true)
f.claim(out, u, redeploy, "claim-all redeploy busy")
f.claim(out, u, all, "claim-all busy")
// 4. Many moons and drills with tight wells, seeded: three rounds of claim-all with
// redeploy, then one without.
u = f.user(p+"-many", nil, "Juno Park")
rnd := rand.New(rand.NewSource(7))
var moons [][]string
for m := 0; m < 25; m++ {
k := 1 + rnd.Intn(7)
res, sup := make([]int, k), make([]float64, k)
for i := range res {
res[i] = rnd.Intn(5)
sup[i] = float64(rnd.Intn(40)) / 2
if rnd.Intn(6) == 0 {
sup[i] = 0
}
}
moons = append(moons, f.moon(u, fmt.Sprintf("%s-n%d", p, m), res, sup))
}
for i := 0; i < 120; i++ {
ws := moons[rnd.Intn(len(moons))]
item := 1 + rnd.Intn(7)
reach := 1
if rnd.Intn(2) == 0 {
reach = []int{2, 3, 5}[rnd.Intn(3)]
}
tool := f.tool(u, fmt.Sprintf("%s-x%03d", p, i), item, rnd.Intn(3), rnd.Intn(3))
f.stake(u, fmt.Sprintf("%s-y%03d", p, i), tool, ws[rnd.Intn(len(ws))], float64(1+rnd.Intn(16))/2, reach, rnd.Intn(5) != 0)
}
for r := 0; r < 3; r++ {
f.claim(out, u, redeploy, fmt.Sprintf("claim-all redeploy round %d", r))
f.finish(u)
}
f.claim(out, u, all, "claim-all final")
}
func (f *fx) moonOf(well string) string {
return f.id("select source_id::text from resource_well where id = $1", well)
}
// TestClaimAllPerf times claim-all for players with n ready drills, and counts its
// statements: one whole-moon drill per moon of three wells, or (crowded) n drills on 25
// moons, a third of them one-well drills, more than the wells hold.
func TestClaimAllPerf(t *testing.T) {
db, tr := testDB(t)
f := newFx(t, db)
type perfRun struct {
n int
redeploy bool
crowded bool
}
runs := []perfRun{{50, false, false}, {50, true, false}, {200, false, false}, {200, true, false},
{900, false, false}, {900, true, false}, {900, false, true}, {900, true, true}}
for _, run := range runs {
n, moons, supply := run.n, run.n, []float64{40, 30, 3}
if run.crowded {
moons, supply = 25, []float64{300, 200, 8}
}
u := f.user(fmt.Sprintf("perf-%d-%v-%v", n, run.redeploy, run.crowded), nil)
for m := 0; m < moons; m++ {
f.moon(u, fmt.Sprintf("p%d-%d", n, m), []int{m, m + 1, m + 2}, supply)
}
_, err := db.Exec(f.ctx, `
insert into inventory_item(user_id, store_item_id) select $1, 1 + (g % 7) from generate_series(1, $2) g`, u, n)
f.must(err)
_, err = db.Exec(f.ctx, `
with tools as (select id, row_number() over (order by id) r from inventory_item where user_id = $1),
moons as (select id, row_number() over (order by name) r from staking_source where user_id = $1)
insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
select $1, (select id from resource_well where source_id = moons.id order by id offset tools.r % 3 limit 1),
tools.id, 10, 5 + tools.r % 4, case when $4 and tools.r % 3 = 0 then 1 else 5 end,
$2::timestamptz + tools.r * interval '1 second'
from tools join moons on moons.r = (tools.r - 1) % $3 + 1`, u, fxBase, moons, run.crowded)
f.must(err)
tr.mu.Lock()
tr.slow = nil
tr.mu.Unlock()
start, n0 := time.Now(), tr.n.Load()
res, err := claimReady(f.ctx, db, u, ClaimScope{Redeploy: run.redeploy})
f.must(err)
t.Logf("drills %d redeploy %v crowded %v: claimed %d redeployed %d stopped %d in %v, %d statements",
n, run.redeploy, run.crowded, res.Claimed, res.Redeployed, res.Stopped,
time.Since(start).Round(time.Millisecond), tr.n.Load()-n0)
for _, s := range tr.slow {
t.Logf(" slow: %.200s", s)
}
}
}
+645 -45
View File
@@ -9,12 +9,14 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"maps"
"math"
mrand "math/rand/v2"
"slices"
"sync"
"time"
"github.com/jackc/pgx/v5"
@@ -138,6 +140,8 @@ type State struct {
// Onboarding flags (onboarding.go): which parts of the game are revealed.
Onboarding []string
stakes *stakeIndex // s.Stakes by tool and by moon (stakeIndex)
}
// MoonGrade is the grade a buy without one gets: the grade of the player's newest moon,
@@ -165,17 +169,79 @@ func (s *State) StoreItem(id int) *StoreItem {
}
func (s *State) StakeForTool(toolID string) *Stake {
for i := range s.Stakes {
if s.Stakes[i].ToolID == toolID {
if i, ok := s.stakeIndex().byTool[toolID]; ok {
return &s.Stakes[i]
}
}
return nil
}
// stakesOn is the running stakes on a moon, in s.Stakes's order (shared: don't change it).
func (s *State) stakesOn(moonID string) []Stake { return s.stakeIndex().byMoon[moonID] }
// stakeIndex groups the stakes by tool and by moon once per state, for the views that
// look them up per tool and per moon. It is rebuilt when s.Stakes changes length (the
// test states append to it after building).
type stakeIndex struct {
n int
first *Stake
byTool map[string]int // the first stake of each tool, its index in s.Stakes
byMoon map[string][]Stake
}
func (s *State) stakeIndex() *stakeIndex {
var first *Stake
if len(s.Stakes) > 0 {
first = &s.Stakes[0]
}
if x := s.stakes; x != nil && x.n == len(s.Stakes) && x.first == first {
return x
}
x := &stakeIndex{n: len(s.Stakes), first: first, byTool: make(map[string]int, len(s.Stakes)),
byMoon: map[string][]Stake{}}
for i, st := range s.Stakes {
if _, ok := x.byTool[st.ToolID]; !ok {
x.byTool[st.ToolID] = i
}
x.byMoon[st.MoonID] = append(x.byMoon[st.MoonID], st)
}
s.stakes = x
return x
}
// ------------------------------------------------------------------ reads
// configCache is the last loadConfig result and the config snapshot it was read under:
// store_item only changes with the config (syncStoreItems, run by migrate, load-config
// and saveOverride, which puts a new cfg() in effect), so a new cfg() pointer reloads it.
// configTTL also re-reads it now and then, for writes by other processes.
var configCache struct {
sync.Mutex
cfg *GameConfig
at time.Time
c Config
}
const configTTL = 30 * time.Second
// loadConfig returns the resources and store items (cached; the slices are fresh copies).
func loadConfig(ctx context.Context, db *pgxpool.Pool) (Config, error) {
c := cfg()
configCache.Lock()
cached, ok := configCache.c, configCache.cfg == c && time.Since(configCache.at) < configTTL
configCache.Unlock()
if !ok {
var err error
if cached, err = queryConfig(ctx, db); err != nil {
return cached, err
}
configCache.Lock()
configCache.cfg, configCache.at, configCache.c = c, time.Now(), cached
configCache.Unlock()
}
return Config{Resources: slices.Clone(cached.Resources), StoreItems: slices.Clone(cached.StoreItems)}, nil
}
func queryConfig(ctx context.Context, db *pgxpool.Pool) (Config, error) {
var cfg Config
rows, err := db.Query(ctx, "select name from resource order by name")
@@ -270,7 +336,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
}
rows, err = db.Query(ctx, `
select id, name, kind, grade, description, coalesce(created_at, 'epoch'), rating from staking_source
select id, name, kind, grade, coalesce(created_at, 'epoch'), rating from staking_source
where user_id = $1 order by created_at, id`, userID)
if err != nil {
return nil, err
@@ -278,7 +344,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
s.Moons, err = pgx.CollectRows(rows, func(row pgx.CollectableRow) (Moon, error) {
var m Moon
var rating *int
err := row.Scan(&m.ID, &m.Name, &m.Kind, &m.Grade, &m.Description, &m.CreatedAt, &rating)
err := row.Scan(&m.ID, &m.Name, &m.Kind, &m.Grade, &m.CreatedAt, &rating)
if rating != nil {
r := ratingOf(*rating)
m.Rating = &r
@@ -299,14 +365,16 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
if err != nil {
return nil, err
}
moonIndex := make(map[string]int, len(s.Moons))
for i := range s.Moons {
moonIndex[s.Moons[i].ID] = i
}
var moonID string
var w Well
_, err = pgx.ForEachRow(rows, []any{&moonID, &w.ID, &w.Resource, &w.Supply}, func() error {
for i := range s.Moons {
if s.Moons[i].ID == moonID {
if i, ok := moonIndex[moonID]; ok {
s.Moons[i].Wells = append(s.Moons[i].Wells, w)
}
}
return nil
})
if err != nil {
@@ -342,12 +410,17 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
return nil, err
}
// Idle tools and the well they last mined (their latest stake is claimed).
// Idle tools and the well they last mined (their latest stake is claimed). Per tool, so
// that it reads one stake per tool (staking_event_tool), not the player's history.
rows, err = db.Query(ctx, `
select distinct on (inventory_item_id) inventory_item_id, well_id::text
from staking_event
where user_id = $1
order by inventory_item_id, created_at desc`, userID)
select inventory_item.id, last.well_id::text
from inventory_item
join lateral (
select well_id from staking_event
where inventory_item_id = inventory_item.id
order by created_at desc limit 1
) last on true
where inventory_item.user_id = $1`, userID)
if err != nil {
return nil, err
}
@@ -445,13 +518,13 @@ func loadPastPage(ctx context.Context, db *pgxpool.Pool, s *State, page int, sel
return nil
}
rows, err := db.Query(ctx, `
select ids.id
from unnest($1::text[]) ids(id)
left join resource_well on resource_well.source_id::text = ids.id
select ids.id::text
from unnest($1::uuid[]) ids(id)
left join resource_well on resource_well.source_id = ids.id
left join staking_event on staking_event.well_id = resource_well.id
left join claim_event on claim_event.staking_event_id = staking_event.id
group by ids.id
order by max(claim_event.created_at) desc nulls last, ids.id
order by max(claim_event.created_at) desc nulls last, ids.id::text
limit $2 offset $3`, ids, pastPerPage, (s.PastPage-1)*pastPerPage)
if err != nil {
return err
@@ -463,15 +536,19 @@ func loadPastPage(ctx context.Context, db *pgxpool.Pool, s *State, page int, sel
if past[selected] != nil && !slices.Contains(load, selected) {
load = append(load, selected)
}
// From the moons' wells to the stakes deployed on them (a claim's wells are all on the
// moon of the stake's well, claimInTx), so that the indexes serve it.
rows, err = db.Query(ctx, `
select resource_well.source_id, resource.name,
sum(coalesce(claim_well.amount, claim_event.claim_amount)), max(claim_event.created_at)
from claim_event
join staking_event on staking_event.id = claim_event.staking_event_id
from resource_well anchor
join staking_event on staking_event.well_id = anchor.id
join claim_event on claim_event.staking_event_id = staking_event.id
left join claim_well on claim_well.claim_event_id = claim_event.id
join resource_well on resource_well.id = coalesce(claim_well.well_id, staking_event.well_id)
join resource on resource.id = resource_well.resource_id
where staking_event.user_id = $1 and resource_well.source_id::text = any($2)
where anchor.source_id = any($2::uuid[]) and staking_event.user_id = $1
and resource_well.source_id = any($2::uuid[])
group by resource_well.source_id, resource.name
order by resource.name`, s.UserID, load)
if err != nil {
@@ -1171,7 +1248,13 @@ type ClaimResult struct {
Stopped int // other drills stopped because the claim emptied their well (stopTapped)
}
// claimReady claims the player's finished stakes in scope.
// claimReady claims the player's finished stakes in scope, oldest first, each from what
// the earlier ones left, and with Redeploy puts each tool back to work right after its
// claim. It goes by the rules of claimInTx, stopTapped and stakeInTx (which stake, redrill
// and moon sales use), but on a copy of the moons in memory: it locks and reads what it
// needs in a few statements, plays the claims through, then writes everything at once. A
// change to those rules goes here too; testdata/claim_golden.txt (claim_db_test.go) is
// what the database looks like after each scenario.
func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope ClaimScope) (ClaimResult, error) {
var res ClaimResult
err := inTx(ctx, db, func(tx pgx.Tx) error {
@@ -1184,8 +1267,6 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
and (not $4 or staking_event.reach <> 1)
and staking_event.created_at + duration_in_secs * interval '1 second' <= now()
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`
// Lock the wells of the moons claimed on first, in id order: a claim may take from
// every well of its moon, and moon sales lock them the same way (resale.go).
rows, err := tx.Query(ctx, "select distinct resource_well.source_id::text"+inScope,
userID, scope.MoonID, scope.WellID, scope.Wide)
if err != nil {
@@ -1195,11 +1276,44 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
select 1 from resource_well where source_id::text = any($1) order by id for update`, moonIDs)
// Lock the wells of the moons claimed on first, in id order: a claim may take from
// every well of its moon, and moon sales lock them the same way (resale.go).
c := &claimer{userID: userID, moons: map[string]*claimMoon{}, wells: map[string]*claimWell{},
stakes: map[string]*claimStake{}, accounts: map[int][]*claimAccount{}}
rows, err = tx.Query(ctx, `
select resource_well.id, resource_well.source_id, staking_source.user_id, staking_source.name,
resource_well.resource_id, resource.name, resource_well.supply
from resource_well
join staking_source on staking_source.id = resource_well.source_id
join resource on resource.id = resource_well.resource_id
where resource_well.source_id::text = any($1)
order by resource_well.id
for update of resource_well`, moonIDs)
if err != nil {
return err
}
var w claimWell
var owner, name string
_, err = pgx.ForEachRow(rows, []any{&w.ID, &w.MoonID, &owner, &name, &w.ResourceID, &w.Resource, &w.Supply}, func() error {
m := c.moons[w.MoonID]
if m == nil {
m = &claimMoon{ID: w.MoonID, Owner: owner, Name: name}
c.moons[w.MoonID] = m
}
cw := w
m.Wells = append(m.Wells, &cw)
c.wells[cw.ID] = &cw
return nil
})
if err != nil {
return err
}
for _, m := range c.moons {
// The moon's order (loadState's): by resource, then id.
slices.SortStableFunc(m.Wells, func(a, b *claimWell) int {
return cmp.Or(cmp.Compare(a.Resource, b.Resource), cmp.Compare(a.ID, b.ID))
})
}
rows, err = tx.Query(ctx, `
select staking_event.id, staking_event.inventory_item_id, staking_event.well_id,
resource_well.source_id`+inScope+`
@@ -1211,24 +1325,44 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
return err
}
type ready struct{ ID, ToolID, WellID, MoonID string }
stakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[ready])
readyStakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[ready])
if err != nil {
return err
}
// Lock the balances up front, in a fixed order, so concurrent claims can't deadlock.
_, err = tx.Exec(ctx, `
select 1 from resource_account where user_id = $1 order by resource_id for update`, userID)
rows, err = tx.Query(ctx, `
select id, resource_id, balance from resource_account where user_id = $1
order by resource_id for update`, userID)
if err != nil {
return err
}
var moons []string // the moons claimed on, once each
for _, st := range stakes {
amount, stopped, err := claimInTx(ctx, tx, userID, st.ID)
if err == errAlreadyClaimed {
continue // claimed by a concurrent request
} else if err != nil {
var a claimAccount
var rid int
_, err = pgx.ForEachRow(rows, []any{&a.ID, &rid, &a.Balance}, func() error {
ca := a
c.accounts[rid] = append(c.accounts[rid], &ca)
return nil
})
if err != nil {
return err
}
if len(readyStakes) == 0 {
return nil
}
readyIDs, toolIDs := make([]string, len(readyStakes)), make([]string, len(readyStakes))
for i, st := range readyStakes {
readyIDs[i], toolIDs[i] = st.ID, st.ToolID
}
if err := c.load(ctx, tx, moonIDs, readyIDs, toolIDs, scope.Redeploy); err != nil {
return err
}
var moons []string // the moons claimed on, once each
for _, st := range readyStakes {
amount, stopped, ok := c.claim(st.ID)
if !ok {
continue // claimed by a concurrent request, or stopped by an earlier claim
}
if !slices.Contains(moons, st.MoonID) {
moons = append(moons, st.MoonID)
}
@@ -1237,7 +1371,7 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
res.Stopped += stopped
if scope.Redeploy {
// A drained well keeps its tools idle instead of failing the whole claim.
switch err := stakeInTx(ctx, tx, userID, st.ToolID, st.WellID); err {
switch err := c.stake(st.ToolID, st.WellID); err {
case nil:
res.Redeployed++
case errWellEmpty, errWellCommitted:
@@ -1247,25 +1381,479 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
}
}
for _, id := range moons {
done, err := moonFinished(ctx, tx, id)
m := c.moons[id]
if !c.finished(m) {
continue
}
res.Finished = append(res.Finished, Moon{ID: id, Name: m.Name})
c.log("moon_finished", map[string]any{"moon": id, "name": m.Name})
}
return c.write(ctx, tx)
})
return res, err
}
// claimer is claimReady's copy of the moons claimed on: their wells, the stakes on them
// not claimed yet, the player's balances, and what has changed, to be written at the end.
type claimer struct {
userID string
now time.Time
moons map[string]*claimMoon
wells map[string]*claimWell
stakes map[string]*claimStake // the unclaimed stakes on the moons, by id
accounts map[int][]*claimAccount // the player's balances, by resource id
tools map[string]*claimTool // the claimed stakes' tools (with Redeploy)
busy map[string][]string // tool → its unclaimed stakes
flags []string // the player's onboarding flags
newFlags []string // flags added
bonus Bonus // with Redeploy: the advisors
speed float64 // and the live events
yield map[string]float64 // per resource
claims []claimEvent // claim_event rows to insert, in order
split []claimSplit // claim_well rows
added []*claimStake // new staking_event rows
stopped []string // stakes stopped (not already locked)
locked map[string]bool // stakes locked by claimReady's query
events []struct{ Kind, Data string } // event_log rows, in order
}
type claimMoon struct {
ID, Owner, Name string
Wells []*claimWell // in the moon's order
Stakes []*claimStake // unclaimed, by start then id
}
type claimWell struct {
ID, MoonID string
ResourceID int
Resource string
Supply float64
changed bool
}
type claimStake struct {
Stake
UserID string
claimed bool
}
type claimAccount struct {
ID string
Balance float64
changed bool
}
type claimTool struct {
StoreItemID int
Lv Levels
}
type claimEvent struct {
ID, StakeID string
Amount float64
}
type claimSplit struct {
ClaimID, WellID string
Amount float64
}
// load reads the rest of what the claims need, the wells being locked: the stakes on the
// moons, onboarding flags and, with redeploy, the tools (locked), their other stakes, the
// advisors and the live events.
func (c *claimer) load(ctx context.Context, tx pgx.Tx, moonIDs, readyIDs, toolIDs []string, redeploy bool) error {
c.locked = map[string]bool{}
for _, id := range readyIDs {
c.locked[id] = true
}
// Every unclaimed stake on the moons (moonFinished counts them all; the rest only the
// player's), oldest first as loadState orders them.
rows, err := tx.Query(ctx, `
select staking_event.id, resource_well.source_id, well_id, inventory_item_id,
resource.name, stake_amount, staking_event.created_at, duration_in_secs,
staking_event.reach, staking_event.user_id
from staking_event
join resource_well on resource_well.id = well_id
join resource on resource.id = resource_well.resource_id
where resource_well.source_id::text = any($1)
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)
order by staking_event.created_at, staking_event.id`, moonIDs)
if err != nil {
return err
}
if !done {
continue
}
m := Moon{ID: id}
if err := tx.QueryRow(ctx, "select name from staking_source where id = $1", id).Scan(&m.Name); err != nil {
var st claimStake
_, err = pgx.ForEachRow(rows, []any{&st.ID, &st.MoonID, &st.WellID, &st.ToolID, &st.Resource, &st.Amount,
&st.Start, &st.DurationInSecs, &st.Reach, &st.UserID}, func() error {
s := st
c.stakes[s.ID] = &s
c.moons[s.MoonID].Stakes = append(c.moons[s.MoonID].Stakes, &s)
return nil
})
if err != nil {
return err
}
res.Finished = append(res.Finished, m)
if err := logEvent(ctx, tx, userID, "moon_finished", map[string]any{"moon": id, "name": m.Name}); err != nil {
err = tx.QueryRow(ctx, "select onboarding, now() from users where id = $1", c.userID).Scan(&c.flags, &c.now)
if err != nil {
return err
}
if !redeploy {
return nil
}
// The tools after the balances, as stakeInTx locks them after claimReady's locks.
rows, err = tx.Query(ctx, `
select inventory_item.id, store_item_id, yield_level, cycle_level from inventory_item
where id = any($1::uuid[]) and user_id = $2
order by id for update`, toolIDs, c.userID)
if err != nil {
return err
}
c.tools = map[string]*claimTool{}
var id string
var t claimTool
_, err = pgx.ForEachRow(rows, []any{&id, &t.StoreItemID, &t.Lv.Yield, &t.Lv.Cycle}, func() error {
ct := t
c.tools[id] = &ct
return nil
})
if err != nil {
return err
}
// A tool is busy while it has an unclaimed stake, on these moons or any other.
rows, err = tx.Query(ctx, `
select inventory_item_id, id from staking_event
where inventory_item_id = any($1::uuid[])
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`,
slices.Collect(maps.Keys(c.tools)))
if err != nil {
return err
}
c.busy = map[string][]string{}
var tool, stake string
_, err = pgx.ForEachRow(rows, []any{&tool, &stake}, func() error {
c.busy[tool] = append(c.busy[tool], stake)
return nil
})
if err != nil {
return err
}
if c.bonus, err = loadBonus(ctx, tx, c.userID); err != nil {
return err
}
if c.speed, err = eventMultiplier(ctx, tx, "cycle_multiplier", "all"); err != nil {
return err
}
// eventMultiplier for every resource at once.
rows, err = tx.Query(ctx, `
select resource.name, (
select coalesce(exp(sum(ln((params->>'multiplier')::float8))), 1)
from live_event
where enabled and kind = 'yield_multiplier' and starts_at <= now() and ends_at > now()
and (params->>'multiplier')::float8 > 0
and coalesce(params->>'resource', 'all') in ('all', resource.name))
from resource`)
if err != nil {
return err
}
c.yield = map[string]float64{}
var res string
var m float64
_, err = pgx.ForEachRow(rows, []any{&res, &m}, func() error {
c.yield[res] = m
return nil
})
return err
}
func (c *claimer) log(kind string, data map[string]any) {
b, err := json.Marshal(data)
if err != nil {
panic(err) // maps of strings and numbers
}
c.events = append(c.events, struct{ Kind, Data string }{kind, string(b)})
}
// unlock is onboarding's unlock on the copy of the flags.
func (c *claimer) unlock(flags ...string) {
if slices.Contains(c.flags, "all") {
return
}
for _, f := range flags {
if !slices.Contains(c.flags, f) {
c.flags = append(c.flags, f)
c.newFlags = append(c.newFlags, f)
}
}
}
// claim is claimInTx: it claims a finished stake, and returns what it got, how many other
// stakes it stopped (stopTapped) by emptying a well, and false if it was already claimed.
func (c *claimer) claim(stakeID string) (float64, int, bool) {
st := c.stakes[stakeID]
if st == nil || st.claimed {
return 0, 0, false
}
m := c.moons[st.MoonID]
// The wells it mines: its own, or (reach > 1) its moon's. A well can't give more than
// it has left, so neither can the moon. (Yield events were applied at stake time.)
wells := m.Wells
if st.Reach <= 1 {
wells = []*claimWell{c.wells[st.WellID]}
}
supply := make([]float64, len(wells))
anchor := -1
for i, w := range wells {
supply[i] = w.Supply
if w.ID == st.WellID {
anchor = i
}
}
take := splitClaim(st.Amount, anchor, supply, st.Reach)
total := 0.0
for _, t := range take {
total += t
}
claimID := randomUUID()
c.claims = append(c.claims, claimEvent{claimID, st.ID, total})
st.claimed = true
for i, w := range wells {
if take[i] <= 0 {
continue
}
for _, a := range c.accounts[w.ResourceID] {
a.Balance += take[i]
a.changed = true
}
w.Supply -= take[i]
w.changed = true
// What a multi-well claim took from each well (loadPastMoons).
if st.Reach > 1 {
c.split = append(c.split, claimSplit{claimID, w.ID, take[i]})
}
c.unlock("market", "claimed:"+w.Resource)
c.log("claim", map[string]any{"resource": w.Resource, "amount": take[i]})
}
// A claim that empties a well stops the drills it leaves with nothing to mine.
for i := range wells {
if take[i] > 0 && supply[i]-take[i] <= 0 {
return total, c.stopTapped(m), true
}
}
return total, 0, true
}
// stopTapped ends the player's unclaimed stakes on the moon that have nothing left to
// mine: those on an empty well and, once the whole moon is empty, the multi-well ones
// (stopTapped in the database).
func (c *claimer) stopTapped(m *claimMoon) int {
empty := !slices.ContainsFunc(m.Wells, func(w *claimWell) bool { return w.Supply > 0 })
n := 0
for _, st := range m.Stakes {
if st.claimed || st.UserID != c.userID {
continue
}
if st.Reach > 1 && !empty || st.Reach <= 1 && c.wells[st.WellID].Supply > 0 {
continue
}
st.claimed = true
c.claims = append(c.claims, claimEvent{randomUUID(), st.ID, 0})
if !c.locked[st.ID] {
c.stopped = append(c.stopped, st.ID)
}
c.log("stake_stopped", map[string]any{"stake": st.ID, "tool": st.ToolID, "well": st.WellID, "reason": "tapped_out"})
n++
}
return n
}
// stake is stakeInTx for a tool whose stake was just claimed (redeploy).
func (c *claimer) stake(toolID, wellID string) error {
t := c.tools[toolID]
if t == nil {
return gameErr("Tool not found")
}
item := configItem(t.StoreItemID)
if item == nil {
return gameErr("Tool not found")
}
// A tool is busy until its stake is claimed.
for _, id := range c.busy[toolID] {
if st := c.stakes[id]; st == nil || !st.claimed {
return gameErr("That tool is already mining")
}
}
w := c.wells[wellID]
if w == nil || c.moons[w.MoonID].Owner != c.userID {
return gameErr("Well not found")
}
m := c.moons[w.MoonID]
// A tool that mines several wells (reach) is tapped out only when the whole moon is:
// on an empty well it goes to the moon's next one with supply (as coverWells walks them).
reach := item.ToolReach()
if w.Supply <= 0 && reach > 1 {
at := slices.Index(m.Wells, w)
for k := 1; k <= len(m.Wells); k++ {
if x := m.Wells[(max(at, 0)+k)%len(m.Wells)]; x.Supply > 0 {
w = x
break
}
}
}
if w.Supply <= 0 {
return errWellEmpty
}
// Advisors and live events count for the stakes started while they apply.
duration, amount := drillStats(item.ToolSecs(t.Lv), item.ToolYield(t.Lv), c.bonus, c.speed, c.yield[w.Resource])
// The drills already on the well (and multi-well ones reaching it) will take all it
// has left: this one would get nothing.
moon := &Moon{ID: m.ID, Wells: make([]Well, len(m.Wells))}
for i, x := range m.Wells {
moon.Wells[i] = Well{ID: x.ID, Resource: x.Resource, Supply: x.Supply}
}
var stakes []Stake
for _, st := range m.Stakes {
if !st.claimed && st.UserID == c.userID {
stakes = append(stakes, st.Stake)
}
}
if newStakeTake(moon, stakes, w.ID, amount, reach) <= 1e-9 {
return errWellCommitted
}
st := &claimStake{UserID: c.userID, Stake: Stake{ID: newStakeID(), MoonID: m.ID, WellID: w.ID, ToolID: toolID,
Resource: w.Resource, Amount: amount, Start: c.now, DurationInSecs: duration, Reach: reach}}
// In its place among the moon's stakes: by start, then id (as the database orders them).
i, _ := slices.BinarySearchFunc(m.Stakes, st, func(a, b *claimStake) int {
return cmp.Or(a.Start.Compare(b.Start), cmp.Compare(a.ID, b.ID))
})
m.Stakes = slices.Insert(m.Stakes, i, st)
c.stakes[st.ID] = st
c.busy[toolID] = append(c.busy[toolID], st.ID)
c.added = append(c.added, st)
c.unlock("equipment")
c.log("stake", map[string]any{"tool": toolID, "well": w.ID, "seconds": duration})
return nil
}
// finished is moonFinished: the moon's wells are empty and nothing on it is left to claim.
func (c *claimer) finished(m *claimMoon) bool {
return !slices.ContainsFunc(m.Wells, func(w *claimWell) bool { return w.Supply > 0 }) &&
!slices.ContainsFunc(m.Stakes, func(st *claimStake) bool { return !st.claimed })
}
// write saves what the claims changed: a statement per table.
func (c *claimer) write(ctx context.Context, tx pgx.Tx) error {
if len(c.stopped) > 0 {
// Locked as stopTapped locks them.
_, err := tx.Exec(ctx, "select 1 from staking_event where id = any($1::uuid[]) order by id for update", c.stopped)
if err != nil {
return err
}
}
if len(c.added) > 0 {
var ids, wells, tools []string
var secs, amounts []float64
var reach []int32
for _, st := range c.added {
ids, wells, tools = append(ids, st.ID), append(wells, st.WellID), append(tools, st.ToolID)
secs, amounts, reach = append(secs, st.DurationInSecs), append(amounts, st.Amount), append(reach, int32(st.Reach))
}
_, err := tx.Exec(ctx, `
insert into staking_event(id, user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach)
select id, $1, well, tool, secs, amount, reach
from unnest($2::uuid[], $3::uuid[], $4::uuid[], $5::float8[], $6::float8[], $7::int[])
with ordinality as t(id, well, tool, secs, amount, reach, n)
order by n`, c.userID, ids, wells, tools, secs, amounts, reach)
if err != nil {
return err
}
}
if len(c.claims) > 0 {
var ids, stakes []string
var amounts []float64
for _, e := range c.claims {
ids, stakes, amounts = append(ids, e.ID), append(stakes, e.StakeID), append(amounts, e.Amount)
}
_, err := tx.Exec(ctx, `
insert into claim_event(id, staking_event_id, claim_amount)
select id, stake, amount from unnest($1::uuid[], $2::uuid[], $3::float8[])
with ordinality as t(id, stake, amount, n)
order by n`, ids, stakes, amounts)
if err != nil {
return err
}
}
if len(c.split) > 0 {
var claims, wells []string
var amounts []float64
for _, s := range c.split {
claims, wells, amounts = append(claims, s.ClaimID), append(wells, s.WellID), append(amounts, s.Amount)
}
_, err := tx.Exec(ctx, `
insert into claim_well(claim_event_id, well_id, amount)
select * from unnest($1::uuid[], $2::uuid[], $3::float8[])`, claims, wells, amounts)
if err != nil {
return err
}
}
// New values, not differences: they were worked out claim by claim, as the database
// would have added them up.
var wellIDs []string
var supply []float64
for _, w := range c.wells {
if w.changed {
wellIDs, supply = append(wellIDs, w.ID), append(supply, w.Supply)
}
}
if len(wellIDs) > 0 {
_, err := tx.Exec(ctx, `
update resource_well set supply = t.supply
from unnest($1::uuid[], $2::float8[]) as t(id, supply)
where resource_well.id = t.id`, wellIDs, supply)
if err != nil {
return err
}
}
var accIDs []string
var balances []float64
for _, as := range c.accounts {
for _, a := range as {
if a.changed {
accIDs, balances = append(accIDs, a.ID), append(balances, a.Balance)
}
}
}
if len(accIDs) > 0 {
_, err := tx.Exec(ctx, `
update resource_account set balance = t.balance
from unnest($1::uuid[], $2::float8[]) as t(id, balance)
where resource_account.id = t.id`, accIDs, balances)
if err != nil {
return err
}
}
if len(c.newFlags) > 0 {
_, err := tx.Exec(ctx, `
update users set onboarding = array(select distinct unnest(onboarding || $2::text[]))
where id = $1`, c.userID, c.newFlags)
if err != nil {
return err
}
}
if len(c.events) > 0 {
kinds, data := make([]string, len(c.events)), make([]string, len(c.events))
for i, e := range c.events {
kinds[i], data[i] = e.Kind, e.Data
}
_, err := tx.Exec(ctx, `
insert into event_log(user_id, kind, data)
select $1, kind, data from unnest($2::text[], $3::jsonb[]) with ordinality as t(kind, data, n)
order by n`, c.userID, kinds, data)
if err != nil {
return err
}
}
return nil
})
return res, err
}
// redrill puts the idle tools whose last stake was on a well back to work on it, picked
@@ -1721,3 +2309,15 @@ func clearUserData(ctx context.Context, db *pgxpool.Pool, userID string) error {
return giveStartingTools(ctx, tx, userID)
})
}
// newStakeID is the id of a new staking_event (claimReady): random, as the column's default.
var newStakeID = randomUUID
// randomUUID is a random (version 4) uuid.
func randomUUID() string {
var b [16]byte
rand.Read(b[:])
b[6] = b[6]&0x0f | 0x40
b[8] = b[8]&0x3f | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
+89 -96
View File
@@ -15,6 +15,8 @@ import (
"math"
"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()}
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"testing"
"time"
)
// TestMarketCache: the cached series match the uncached rates, and a new config snapshot
// (the economy editor's override) is picked up at once.
func TestMarketCache(t *testing.T) {
old := cfg()
defer effective.Store(old)
now := marketTick(time.Now())
item := "Sollux"
m := marketSeriesAt(item, now)
for _, tick := range []int64{now, now - 1, now - marketPoints + 1} {
if got, want := marketRateAt(item, tick), rateAt(old, item, tick); got != want {
t.Errorf("tick %d: cached %v, computed %v", tick, got, want)
}
}
if m.rate != rateAt(old, item, now) || m.open != rateAt(old, item, now-marketPoints+1) {
t.Errorf("series ends %v %v", m.open, m.rate)
}
c := baseConfig()
c.ResourcePrices[item] *= 2
effective.Store(&c)
if got, want := marketSeriesAt(item, now).rate, rateAt(&c, item, now); got != want || got == m.rate {
t.Errorf("after a config change: %v, want %v (was %v)", got, want, m.rate)
}
if got := marketRateAt(item, now-5); got != rateAt(&c, item, now-5) {
t.Errorf("after a config change, rate %v", got)
}
}
+11
View File
@@ -605,3 +605,14 @@ func runMoonTraits() error {
enc.SetIndent("", " ")
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)
}
+2 -1
View File
@@ -72,7 +72,8 @@ func serve(ctx context.Context) error {
// 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() {
mux.Handle("GET /", http.FileServerFS(os.DirFS(webDir)))
// "/", 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)
@@ -0,0 +1,8 @@
-- Indexes for the per-request state reads (loadState, loadPastPage) and the stake and
-- claim checks: without them each request scanned these tables for every player.
create index if not exists staking_source_user on staking_source (user_id);
create index if not exists resource_well_source on resource_well (source_id);
create index if not exists inventory_item_user on inventory_item (user_id);
create index if not exists staking_event_user on staking_event (user_id, created_at);
create index if not exists staking_event_well on staking_event (well_id);
create index if not exists staking_event_tool on staking_event (inventory_item_id, created_at);
+6
View File
@@ -41,6 +41,7 @@ create table staking_source (
constraint fk_user_staking_source foreign key(user_id)
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(),
+5187
View File
File diff suppressed because it is too large. Load diff
+22 -10
View File
@@ -257,7 +257,6 @@ type TrackRow struct {
RateFrom float64 // credits per hour (creditsPerHour) now and after the next level
RateTo float64
Cost float64 // the next level's price plus its parts at sell value
Ladder []TrackStep // every level, the other track as it is
}
// TrackStep is one level of a track, for the drill panel's ladder.
@@ -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)
+2 -9
View File
@@ -626,8 +626,7 @@ func (s *State) page(v View) *Page {
for _, m := range s.Moons {
row := MoonListRow{Moon: m, Selected: m.ID == v.Moon, Over: s.wellsOver(&m)}
var pending []time.Time
for _, st := range s.Stakes {
if st.MoonID == m.ID {
for _, st := range s.stakesOn(m.ID) {
row.Drills++
if st.Remaining(s.Now) == 0 {
row.Ready++
@@ -638,7 +637,6 @@ func (s *State) page(v View) *Page {
}
}
}
}
allPending = append(allPending, pending...)
row.Pending = endsAttr(pending)
p.Ready += row.Ready
@@ -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