Compare commits
21
Commits
429199f462
...
main
No files matched your search
@@ -36,6 +36,11 @@ banned players left out).
|
||||
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
|
||||
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
|
||||
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
|
||||
- A worktree's `.env` names its own database (`DB_DATABASE=mm_<task>`), so `migrate`,
|
||||
tests and servers run there reach only `mm_<task>`. Never point anything at the main
|
||||
database (`moonminers`): it holds the author's game.
|
||||
- Never use `git stash` in a worktree: the stash list is shared by every worktree, so a
|
||||
pop can take another agent's work. Commit on your branch, or save a patch file.
|
||||
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
||||
commit it.
|
||||
|
||||
|
||||
+5
-1
@@ -42,7 +42,11 @@ map.
|
||||
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
|
||||
layout is top-left based (x right, y down).
|
||||
- Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua;
|
||||
`ASIDE_KEYS`, header and moon list sigs likewise) or it won't update. What must survive
|
||||
`ASIDE_KEYS` and the header's sig likewise; the moon list's rows: `moonlist.rows_sig`;
|
||||
the moon's facts in the aside: `aside.facts_sig`; the Moons parts: their models in
|
||||
moons.lua) or it won't update. The shown state is frozen (`blocks.freeze`: `blocks.sig`
|
||||
writes each part once per state, by content): never change `g.state` in place; a
|
||||
prediction changes its copy before it is shown. What must survive
|
||||
a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across
|
||||
builds outside its block. A new per-build registry notes its entries in the block journal
|
||||
(`blocks.note`/`push`/`put`); code that builds outside a build runs through
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
-- local l = list.new(g, parent, x, y, w, h, {
|
||||
-- key = "moons", row_h = 64, -- the row's slot (gap included)
|
||||
-- create = function(item, index) ... return root[, druid component] end,
|
||||
-- focus_id = function(item) return "moon:" .. item.id end }) -- (optional) the test
|
||||
-- focus_id = function(item) return "moon:" .. item.id end, -- (optional) the test
|
||||
-- -- id its row registers: Tab walks
|
||||
-- -- every row, scrolling (modules/focus)
|
||||
-- on_remove = function(node, item) ... end }) -- (optional) a row scrolled out, before
|
||||
-- -- its node is deleted (not when the
|
||||
-- -- list itself goes in a rebuild)
|
||||
-- l:set_data(items) -- (again with a filtered list: the rows are made again)
|
||||
--
|
||||
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
|
||||
@@ -43,6 +46,13 @@ function M.new(g, parent, x, y, w, h, opts)
|
||||
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
|
||||
return l.create(item, index)
|
||||
end)
|
||||
if opts.on_remove then
|
||||
l.data.on_element_remove:subscribe(function(_, _, node, _, item)
|
||||
if not g.tearing_down then
|
||||
opts.on_remove(node, item)
|
||||
end
|
||||
end)
|
||||
end
|
||||
l.scroll.on_scroll:subscribe(function(_, pos)
|
||||
if g.tearing_down then
|
||||
return -- (the old list emptying itself during a rebuild: keep the offset)
|
||||
@@ -55,15 +65,16 @@ function M.new(g, parent, x, y, w, h, opts)
|
||||
end
|
||||
|
||||
function M:set_data(items)
|
||||
local saved = self.g.scroll_pos[self.key] or 0 -- (setting the data scrolls to the top)
|
||||
local saved = self.g.scroll_pos[self.key] or 0
|
||||
self.items = items
|
||||
self.data:set_data(items)
|
||||
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the
|
||||
-- scroll event).
|
||||
-- At the saved offset first (Druid clamps it to the rows), so the data list makes the
|
||||
-- rows there, not the top ones only to drop them again.
|
||||
local s = self.scroll
|
||||
s:set_size(self.grid:get_size_for(#items))
|
||||
s.target_position.y = saved
|
||||
s:_set_scroll_position(s.position.x, saved)
|
||||
s.target_position.y = s.position.y
|
||||
self.data:set_data(items)
|
||||
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
|
||||
for _, a in ipairs(self.g.scroll_areas) do
|
||||
if a.list == self then
|
||||
|
||||
@@ -36,9 +36,50 @@ end
|
||||
|
||||
---------------------------------------------------------------- signatures
|
||||
|
||||
local function ser(v, buf)
|
||||
-- Frozen tables (M.freeze: a state's parts, which nothing changes once shown) are written
|
||||
-- as a token of their content, worked out once per table: two tables with the same
|
||||
-- content get the same token, so a block whose inputs the server's answer left as the
|
||||
-- prediction had them is kept, and a list as long as the moons is serialized once per
|
||||
-- state, not once per block and build. (A frozen table must not change.)
|
||||
local frozen = setmetatable({}, { __mode = "k" }) -- table -> true (not worked out yet) or its token
|
||||
local tokens, token_bytes, token_n = {}, 0, 0 -- content -> token; the content strings' size
|
||||
local TOKEN_BYTES = 4 * 1024 * 1024 -- (then forgotten: equal content gets a new token once)
|
||||
|
||||
local ser
|
||||
|
||||
local function token_of(v)
|
||||
local tok = frozen[v]
|
||||
if tok ~= true then
|
||||
return tok
|
||||
end
|
||||
local buf = {}
|
||||
frozen[v] = false -- (while it's written: a table inside itself is written out)
|
||||
ser(v, buf, true)
|
||||
local s = table.concat(buf)
|
||||
tok = tokens[s]
|
||||
if not tok then
|
||||
if token_bytes > TOKEN_BYTES then
|
||||
tokens, token_bytes = {}, 0
|
||||
end
|
||||
token_n = token_n + 1
|
||||
tok = "#" .. token_n
|
||||
tokens[s], token_bytes = tok, token_bytes + #s
|
||||
end
|
||||
frozen[v] = tok
|
||||
return tok
|
||||
end
|
||||
|
||||
local function key_lt(a, b)
|
||||
return tostring(a) < tostring(b)
|
||||
end
|
||||
|
||||
ser = function(v, buf, own)
|
||||
local t = type(v)
|
||||
if t == "table" then
|
||||
if not own and frozen[v] then
|
||||
buf[#buf + 1] = token_of(v)
|
||||
return
|
||||
end
|
||||
buf[#buf + 1] = "{"
|
||||
local n = #v
|
||||
for i = 1, n do
|
||||
@@ -55,7 +96,7 @@ local function ser(v, buf)
|
||||
end
|
||||
if keys then
|
||||
if mixed then
|
||||
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
|
||||
table.sort(keys, key_lt)
|
||||
else
|
||||
table.sort(keys)
|
||||
end
|
||||
@@ -68,7 +109,9 @@ local function ser(v, buf)
|
||||
end
|
||||
buf[#buf + 1] = "}"
|
||||
elseif t == "string" then
|
||||
buf[#buf + 1] = #v .. ":" .. v
|
||||
buf[#buf + 1] = #v
|
||||
buf[#buf + 1] = ":"
|
||||
buf[#buf + 1] = v
|
||||
elseif t == "function" then
|
||||
buf[#buf + 1] = "f"
|
||||
else
|
||||
@@ -76,7 +119,8 @@ local function ser(v, buf)
|
||||
end
|
||||
end
|
||||
|
||||
-- A string that is the same exactly when the values are (tables by content, keys sorted).
|
||||
-- A string that is the same exactly when the values are (tables by content, keys sorted;
|
||||
-- frozen ones by their token, the same for the same content).
|
||||
function M.sig(...)
|
||||
local buf = {}
|
||||
for i = 1, select("#", ...) do
|
||||
@@ -86,6 +130,23 @@ function M.sig(...)
|
||||
return table.concat(buf)
|
||||
end
|
||||
|
||||
-- Freeze the tables in t (a state: its lists and records, one level down; t itself too)
|
||||
-- for M.sig: nothing may change them afterwards (a prediction changes its copy before).
|
||||
function M.freeze(t)
|
||||
if type(t) ~= "table" then
|
||||
return t
|
||||
end
|
||||
if frozen[t] == nil then
|
||||
frozen[t] = true
|
||||
end
|
||||
for _, v in pairs(t) do
|
||||
if type(v) == "table" and frozen[v] == nil then
|
||||
frozen[v] = true
|
||||
end
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- registrations
|
||||
|
||||
-- Note a registration made while building (fn(...) makes it again in a later build that
|
||||
@@ -216,6 +277,37 @@ local function rezone(b)
|
||||
end
|
||||
end
|
||||
|
||||
-- After a kept block is taken: its Druid components (and those of the blocks inside it)
|
||||
-- get new uids, in their order, as if made now. Druid hands input to the newest first
|
||||
-- (by uid: druid_instance's sort_input_comparator), so a kept button inside a box rebuilt
|
||||
-- around it (a slot in a well card, which selects the well when clicked) still gets its
|
||||
-- clicks first, as a rebuilt one would.
|
||||
local new_uid = require("druid.component").create_uid
|
||||
local function renew(c)
|
||||
local cc = c._component
|
||||
if cc and cc._uid then
|
||||
cc._uid = new_uid()
|
||||
cc._is_input_priority_changed = true -- (the instance sorts its input stack again)
|
||||
end
|
||||
local kids = c.get_childrens and c:get_childrens() or {}
|
||||
for i = 1, #kids do -- (already the whole subtree, in its order)
|
||||
local k = kids[i]._component
|
||||
if k and k._uid then
|
||||
k._uid = new_uid()
|
||||
end
|
||||
end
|
||||
end
|
||||
local function reorder(b)
|
||||
for _, c in ipairs(b.comps) do
|
||||
pcall(renew, c)
|
||||
end
|
||||
for _, e in ipairs(b.journal) do
|
||||
if e.block and not e.block.dead then
|
||||
reorder(e.block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- True while a kept block's registrations are made again (test.register looks for its
|
||||
-- clip zone again then: the one it was given may have gone with the old parent).
|
||||
function M.replaying()
|
||||
@@ -318,6 +410,7 @@ function M.open(g, key, sig, parent)
|
||||
reg.used[key] = true
|
||||
gui.set_parent(old.root, parent, false)
|
||||
rezone(old)
|
||||
reorder(old)
|
||||
adopt(reg, old)
|
||||
return old, true
|
||||
end
|
||||
|
||||
@@ -247,12 +247,16 @@ end
|
||||
|
||||
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
|
||||
-- the item before / after the focused row (or the first / last when coming from outside),
|
||||
-- scrolled into view; the rebuild makes its row, which then has the focus. Returns true
|
||||
-- when it moved the focus.
|
||||
-- scrolled into view; the rebuild makes its row, which then has the focus. Within the rows
|
||||
-- in view Tab goes in order (a row's own buttons too); from a button inside a row it steps
|
||||
-- from that row (the one under it). Returns true when it moved the focus.
|
||||
local function list_step(g, f, cur, e, dir, scope_)
|
||||
local a = list_of(g, cur)
|
||||
local items, i
|
||||
if a then
|
||||
if list_of(g, e) == a then
|
||||
return false -- (the next element is in view in the same list)
|
||||
end
|
||||
items = a.list.items
|
||||
local id = cur.base:sub(#scope_ + 2)
|
||||
for n, item in ipairs(items) do
|
||||
@@ -260,6 +264,11 @@ local function list_step(g, f, cur, e, dir, scope_)
|
||||
i = n + dir
|
||||
end
|
||||
end
|
||||
if not i and a.list.row_h then
|
||||
local r, v = M.rect(cur.node), M.rect(a.view)
|
||||
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
|
||||
i = math.floor((r.y + r.h / 2 - v.y + pos) / a.list.row_h) + 1 + dir
|
||||
end
|
||||
if not i or i < 1 or i > #items then
|
||||
return false -- (leaving the list: the next element on the page)
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -146,9 +146,9 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
|
||||
if g.autoplay then
|
||||
local p = type(params) == "function" and params() or params
|
||||
g.autoplay:register(name, p, fire)
|
||||
blocks.note(function() -- (a kept block registers again)
|
||||
blocks.note(function() -- (a kept block registers again; params made now)
|
||||
if g.autoplay then
|
||||
g.autoplay:register(name, p, fire)
|
||||
g.autoplay:register(name, type(params) == "function" and params() or params, fire)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -283,14 +283,25 @@ local function build_main(g, parent, x, y, w, h)
|
||||
end
|
||||
|
||||
-- The aside's block (narrow: the row under the panels).
|
||||
local function build_aside(g, parent, x, y, w, h, row, common)
|
||||
local function build_aside(g, parent, x, y, w, h, row, common, still)
|
||||
local st = g.state
|
||||
local section = g.view.section
|
||||
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
|
||||
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
|
||||
return blocks.keep(g, "aside", sig, parent, function(holder)
|
||||
-- (on Moons with a moon the panel only shows the viewport: its facts are a block of
|
||||
-- their own, below)
|
||||
local moon_only = section == "moons" and st.moon ~= nil
|
||||
local sig = blocks.sig(moon_only and still or common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
|
||||
moon_only and blocks.sig(st.moon.id, st.view and st.view.aside, st.catalogVersion or st.catalog or false)
|
||||
or pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
|
||||
local p, facts = blocks.keep(g, "aside", sig, parent, function(holder)
|
||||
return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
|
||||
end)
|
||||
if facts then
|
||||
-- The moon's facts: a block of their own inside the panel (aside.facts).
|
||||
blocks.keep(g, "aside:facts", aside.facts_sig(g, facts, g.h), facts.root, function(holder)
|
||||
aside.facts(g, holder, facts, g.h)
|
||||
end)
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
-- A full rebuild drops every node and component and builds them all again; otherwise the
|
||||
@@ -360,11 +371,15 @@ function M.build(g)
|
||||
g.layout_state = true
|
||||
local st = g.state
|
||||
local common = local_sig(g)
|
||||
-- (without the drills and batches that ended on our clock: the banner, header and moon
|
||||
-- list show those counts through their own signatures, and a drill ending mustn't
|
||||
-- rebuild them)
|
||||
local still = local_sig(g, {})
|
||||
-- Announcements and live events above the header.
|
||||
local banner_h = blocks.keep(g, "banner", blocks.sig(common, W, st.banner), g.layout, function(holder)
|
||||
local banner_h = blocks.keep(g, "banner", blocks.sig(still, W, st.banner), g.layout, function(holder)
|
||||
return banner.build(g, g.h, holder, W)
|
||||
end)
|
||||
local header_h = blocks.keep(g, "header", blocks.sig(common, W, banner_h, g.view.section, st.credits, st.resources,
|
||||
local header_h = blocks.keep(g, "header", blocks.sig(still, W, banner_h, g.view.section, st.credits, st.resources,
|
||||
st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
|
||||
g.tab_nodes = {}
|
||||
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
|
||||
@@ -438,8 +453,8 @@ function M.build(g)
|
||||
for _, m in ipairs(st.moons) do
|
||||
hides[#hides + 1] = g.reveal:hides(m.id) or false
|
||||
end
|
||||
blocks.keep(g, "moonlist", blocks.sig(common, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
|
||||
st.moons, st.past, st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
|
||||
blocks.keep(g, "moonlist", blocks.sig(still, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
|
||||
moonlist.rows_sig(g), st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
|
||||
st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder)
|
||||
g.moon_nodes = {}
|
||||
moonlist.teardown(g)
|
||||
@@ -462,7 +477,7 @@ function M.build(g)
|
||||
end
|
||||
end
|
||||
if page then
|
||||
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common)
|
||||
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common, still)
|
||||
local s = page:finish(y0 + body_h + gap + row_h + pad)
|
||||
if g.viewport_node and not g.reveal:active() then
|
||||
moon_view.move(g.viewport_node) -- (where the page's offset put it)
|
||||
@@ -487,7 +502,7 @@ function M.build(g)
|
||||
end)
|
||||
end
|
||||
elseif has_aside then
|
||||
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common)
|
||||
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common, still)
|
||||
else
|
||||
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
||||
if not g.reveal:active() then -- (the reveal's window shows the moon)
|
||||
|
||||
@@ -15,6 +15,7 @@ local predict = require("modules.predict")
|
||||
local sfx = require("modules.sfx")
|
||||
local refinery = require("screens.game.refinery")
|
||||
local sidepanel = require("screens.game.sidepanel")
|
||||
local blocks = require("modules.blocks")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -190,7 +191,8 @@ end
|
||||
|
||||
-- What we show: the last server state (g.base) with the pending predictions on top.
|
||||
local function show(g)
|
||||
g.state = predict.apply(g.base, g.pending)
|
||||
-- (frozen: what is shown never changes, so blocks.sig writes each part once per state)
|
||||
g.state = blocks.freeze(predict.apply(g.base, g.pending))
|
||||
g.view = g.state.view
|
||||
arm_refetch(g)
|
||||
g.dirty = true
|
||||
@@ -460,7 +462,7 @@ function M.act(g, name, params, on_done, fn)
|
||||
if predict.run(st, e) then
|
||||
g.pending[#g.pending + 1] = e
|
||||
local prev = g.state
|
||||
g.state = st
|
||||
g.state = blocks.freeze(st)
|
||||
g.dirty = true
|
||||
predicted = true
|
||||
-- The action's effects play now, from the predicted change (fx.lua).
|
||||
|
||||
@@ -12,6 +12,7 @@ local ui = require("modules.ui")
|
||||
local tooltip = require("modules.tooltip")
|
||||
local cards = require("screens.game.cards")
|
||||
local scroll = require("components.scroll")
|
||||
local list = require("components.list")
|
||||
local store = require("screens.game.store")
|
||||
local row_c = require("components.row")
|
||||
local blocks = require("modules.blocks")
|
||||
@@ -38,6 +39,13 @@ end
|
||||
|
||||
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||
|
||||
-- A tool's track's ladder: state.equipment.ladders holds each once per store item and
|
||||
-- levels, the row names its own (ladderKey).
|
||||
local function ladder_of(st, t, tr)
|
||||
local l = (st.equipment.ladders or {})[t.ladderKey or ""]
|
||||
return l and l[tr.kind] or {}
|
||||
end
|
||||
|
||||
-- Stats: Yield and Cycle (a stake's now), wrapping (rows 20 tall, 16 apart). A tree piece.
|
||||
local function stats_box(st, parent, t)
|
||||
local items = { { "Yield", ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) } }
|
||||
@@ -89,7 +97,7 @@ local function track_box(self, H, st, parent, t, tr, w)
|
||||
-- this width), so the box (and the card) stays the same height from level to level.
|
||||
local parts_h = 0
|
||||
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
|
||||
for _, r in ipairs(tr.ladder or {}) do
|
||||
for _, r in ipairs(ladder_of(st, t, tr)) do
|
||||
if #(r.parts or {}) > 0 then
|
||||
parts_h = math.max(parts_h, cards.parts(st, probe, r.parts, 0, 0, w))
|
||||
end
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
-- A card's height for its slots (its tree's, below: the head lines, then the slots).
|
||||
local CARD_HEAD = 18 + 7 + 6 + 4 + 16 + 8
|
||||
local function card_h(slots)
|
||||
return CARD_PAD * 2 + CARD_HEAD + (slots > 0 and (slots * SLOT_H + (slots - 1) * SLOT_GAP) or 0)
|
||||
end
|
||||
|
||||
-- One well's card, w x h at the origin of c (its node selects the well when clicked).
|
||||
-- m: card_model.
|
||||
local function well_card(g, c, st, moon, m, w, h, H)
|
||||
local well = m.well
|
||||
local dry = m.dry
|
||||
local scope = { ["claim-well"] = well.id }
|
||||
local enters = {}
|
||||
local slots = {}
|
||||
if m.deploy then
|
||||
slots[#slots + 1] = deploy_slot(g, well, m.deploy, m.marks, H)
|
||||
end
|
||||
if (well.redrill or 0) > 0 then
|
||||
slots[#slots + 1] = idle_slot(g, st, well, H)
|
||||
for i, d in ipairs(well.drills or {}) do
|
||||
local dm = m.drills[i]
|
||||
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
|
||||
end
|
||||
if m.idle then
|
||||
slots[#slots + 1] = idle_slot(g, well, m.idle, H)
|
||||
end
|
||||
local sup = fit(c, ui.money(well.supply), { font = "mono", px = 13, color = dry and "muted" or "text" }, { shrink = 0 })
|
||||
sup.done = function(node) H.tick_num(g, node, "supply:" .. well.id, well.supply) end
|
||||
local list = col({ gap = SLOT_GAP }, slots)
|
||||
local kids = {
|
||||
row({ height = 18, align = "center" }, {
|
||||
made({ width = 8, height = 8, shrink = 0 }, function(q, x, y) ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1) end),
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -204,7 +204,7 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
|
||||
}
|
||||
for _, tr := range r.Tracks {
|
||||
if n := tr.Next; n != nil && ((n.Blocked != "") != (n.BlockedText != "") || n.Label == "") ||
|
||||
len(tr.Ladder) != tr.Max+1 {
|
||||
len(e.Ladders[r.LadderKey][tr.Kind]) != tr.Max+1 {
|
||||
fail("track texts", tr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
package main
|
||||
|
||||
// Claim-all against a real database: TestClaimGolden plays fixed scenarios (one-well and
|
||||
// multi-well drills on shared wells, wells emptied mid-claim, redeploy on and off, every
|
||||
// scope, reach 1, 3 and 5) and compares what they leave in the database with
|
||||
// testdata/claim_golden.txt; TestClaimAllPerf times claim-all and counts its statements.
|
||||
// Both need MM_TEST_DATABASE, a migrated database of their own (scripts/wt makes one:
|
||||
// mm_<task>); without it they are skipped. MM_CLAIM_GOLDEN_WRITE=1 rewrites the golden file.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// countTracer counts the statements a pool sends and keeps the slowest.
|
||||
type countTracer struct {
|
||||
n atomic.Int64
|
||||
mu sync.Mutex
|
||||
slow []string
|
||||
}
|
||||
|
||||
type traceStart struct {
|
||||
sql string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (c *countTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, d pgx.TraceQueryStartData) context.Context {
|
||||
c.n.Add(1)
|
||||
return context.WithValue(ctx, c, traceStart{d.SQL, time.Now()})
|
||||
}
|
||||
func (c *countTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryEndData) {
|
||||
if s, ok := ctx.Value(c).(traceStart); ok {
|
||||
if d := time.Since(s.at); d > 10*time.Millisecond {
|
||||
c.mu.Lock()
|
||||
c.slow = append(c.slow, fmt.Sprintf("%v %s", d.Round(time.Millisecond), strings.Join(strings.Fields(s.sql), " ")))
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
func (c *countTracer) TraceBatchStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceBatchStartData) context.Context {
|
||||
return ctx
|
||||
}
|
||||
func (c *countTracer) TraceBatchQuery(context.Context, *pgx.Conn, pgx.TraceBatchQueryData) {
|
||||
c.n.Add(1)
|
||||
}
|
||||
func (c *countTracer) TraceBatchEnd(context.Context, *pgx.Conn, pgx.TraceBatchEndData) {}
|
||||
|
||||
func testDB(t *testing.T) (*pgxpool.Pool, *countTracer) {
|
||||
t.Helper()
|
||||
name := os.Getenv("MM_TEST_DATABASE")
|
||||
if name == "" {
|
||||
t.Skip("MM_TEST_DATABASE not set")
|
||||
}
|
||||
if name == "moonminers" {
|
||||
t.Fatal("MM_TEST_DATABASE must not be the game's database")
|
||||
}
|
||||
if !loadDotEnv(".env") {
|
||||
loadDotEnv("../.env")
|
||||
}
|
||||
os.Setenv("DB_DATABASE", name)
|
||||
pc, err := pgxpool.ParseConfig(appDSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := &countTracer{}
|
||||
pc.ConnConfig.Tracer = tr
|
||||
db, err := pgxpool.NewWithConfig(context.Background(), pc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
return db, tr
|
||||
}
|
||||
|
||||
// fx builds players straight in the database and names every row it makes (labels), so
|
||||
// what a claim leaves can be compared across runs.
|
||||
type fx struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
db *pgxpool.Pool
|
||||
run uint32 // the first part of the ids the fixture makes (orders them as made)
|
||||
ids int
|
||||
label map[string]string // id → label
|
||||
res map[string]int // resource name → id
|
||||
resN []string // resource names, sorted
|
||||
clock int // seconds after base for the next stake
|
||||
action int
|
||||
}
|
||||
|
||||
var fxBase = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func newFx(t *testing.T, db *pgxpool.Pool) *fx {
|
||||
f := &fx{t: t, ctx: context.Background(), db: db, label: map[string]string{}, res: map[string]int{},
|
||||
run: rand.Uint32()}
|
||||
rows, err := db.Query(f.ctx, "select id, name from resource order by name")
|
||||
f.must(err)
|
||||
var id int
|
||||
var n string
|
||||
_, err = pgx.ForEachRow(rows, []any{&id, &n}, func() error {
|
||||
f.res[n] = id
|
||||
f.resN = append(f.resN, n)
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
if len(f.resN) < 5 {
|
||||
t.Fatalf("resources: %v", f.resN)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fx) must(err error) {
|
||||
f.t.Helper()
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fx) id(q string, args ...any) string {
|
||||
f.t.Helper()
|
||||
var id string
|
||||
f.must(f.db.QueryRow(f.ctx, q, args...).Scan(&id))
|
||||
return id
|
||||
}
|
||||
|
||||
func (f *fx) user(label string, onboarding []string, advisors ...string) string {
|
||||
if onboarding == nil {
|
||||
onboarding = []string{}
|
||||
}
|
||||
u := f.id("insert into users(name, onboarding) values ($1, $2) returning id::text", label, onboarding)
|
||||
f.label[u] = label
|
||||
f.t.Cleanup(func() {
|
||||
f.db.Exec(f.ctx, "delete from event_log where user_id = $1", u)
|
||||
f.db.Exec(f.ctx, "delete from users where id = $1", u)
|
||||
})
|
||||
_, err := f.db.Exec(f.ctx, "insert into bank_account(user_id, balance) values ($1, 1000)", u)
|
||||
f.must(err)
|
||||
_, err = f.db.Exec(f.ctx, `insert into resource_account(resource_id, user_id, balance)
|
||||
select id, $1, 0.1 * id from resource`, u)
|
||||
f.must(err)
|
||||
for i, a := range advisors {
|
||||
_, err = f.db.Exec(f.ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", u, a, i+1)
|
||||
f.must(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// moon makes a moon with wells of the given resources (indexes into resN) and supplies;
|
||||
// the wells are labelled <moon>.<i>.
|
||||
func (f *fx) moon(user, label string, res []int, supply []float64) []string {
|
||||
m := f.id(`insert into staking_source(name, description, user_id, address, grade)
|
||||
values ($1, '', $2, '', 'C') returning id::text`, label, user)
|
||||
f.label[m] = label
|
||||
var wells []string
|
||||
for i, r := range res {
|
||||
f.ids++
|
||||
w := f.id("insert into resource_well(id, resource_id, source_id, supply) values ($1, $2, $3, $4) returning id::text",
|
||||
fmt.Sprintf("%08x-0000-4000-8000-%012x", f.run, f.ids), f.res[f.resN[r%len(f.resN)]], m, supply[i])
|
||||
f.label[w] = fmt.Sprintf("%s.%d", label, i)
|
||||
wells = append(wells, w)
|
||||
}
|
||||
return wells
|
||||
}
|
||||
|
||||
func (f *fx) tool(user, label string, item, yl, cl int) string {
|
||||
id := f.id("insert into inventory_item(user_id, store_item_id, yield_level, cycle_level) values ($1, $2, $3, $4) returning id::text",
|
||||
user, item, yl, cl)
|
||||
f.label[id] = label
|
||||
return id
|
||||
}
|
||||
|
||||
// stake puts a stake in: ready (started at the next tick of the fixture clock, long ago)
|
||||
// or still running.
|
||||
func (f *fx) stake(user, label, tool, well string, amount float64, reach int, ready bool) string {
|
||||
f.clock++
|
||||
at, secs := fxBase.Add(time.Duration(f.clock)*time.Second), 30.0
|
||||
if !ready {
|
||||
at, secs = time.Now().Add(time.Duration(f.clock)*time.Second), 1e8
|
||||
}
|
||||
id := f.id(`insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
|
||||
values ($1, $2, $3, $4, $5, $6, $7) returning id::text`, user, well, tool, secs, amount, reach, at)
|
||||
f.label[id] = label
|
||||
return id
|
||||
}
|
||||
|
||||
// finish makes every running stake of the player ready, in the order of their tools' labels
|
||||
// (deterministic, unlike the random ids that order stakes started together).
|
||||
func (f *fx) finish(user string) {
|
||||
rows, err := f.db.Query(f.ctx, `select id::text, inventory_item_id::text from staking_event
|
||||
where user_id = $1 and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`, user)
|
||||
f.must(err)
|
||||
type st struct{ ID, Tool string }
|
||||
stakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[st])
|
||||
f.must(err)
|
||||
sort.Slice(stakes, func(i, j int) bool { return f.label[stakes[i].Tool] < f.label[stakes[j].Tool] })
|
||||
for _, s := range stakes {
|
||||
f.clock++
|
||||
_, err := f.db.Exec(f.ctx, "update staking_event set created_at = $2, duration_in_secs = least(duration_in_secs, 30) where id = $1",
|
||||
s.ID, fxBase.Add(time.Duration(f.clock)*time.Second))
|
||||
f.must(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fnum(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
|
||||
|
||||
// claim runs claimReady and writes the result and the player's whole state to out.
|
||||
func (f *fx) claim(out *strings.Builder, user string, scope ClaimScope, what string) {
|
||||
f.action++
|
||||
var lastLog int64
|
||||
f.must(f.db.QueryRow(f.ctx, "select coalesce(max(id), 0) from event_log where user_id = $1", user).Scan(&lastLog))
|
||||
res, err := claimReady(f.ctx, f.db, user, scope)
|
||||
fmt.Fprintf(out, "== %s %s: %s\n", f.label[user], what, f.lbl(scope.MoonID+scope.WellID))
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "error: %v\n", err)
|
||||
} else {
|
||||
var fin []string
|
||||
for _, m := range res.Finished {
|
||||
fin = append(fin, f.label[m.ID]+"="+m.Name)
|
||||
}
|
||||
fmt.Fprintf(out, "claimed %d redeployed %d total %s stopped %d finished %v\n",
|
||||
res.Claimed, res.Redeployed, fnum(res.Total), res.Stopped, fin)
|
||||
}
|
||||
f.dump(out, user, lastLog)
|
||||
}
|
||||
|
||||
func (f *fx) lbl(id string) string {
|
||||
if l, ok := f.label[id]; ok {
|
||||
return l
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (f *fx) dump(out *strings.Builder, user string, lastLog int64) {
|
||||
q := func(sql string, args ...any) pgx.Rows {
|
||||
rows, err := f.db.Query(f.ctx, sql, args...)
|
||||
f.must(err)
|
||||
return rows
|
||||
}
|
||||
// Name the stakes this action made: tool@action.
|
||||
rows := q("select id::text, inventory_item_id::text from staking_event where user_id = $1", user)
|
||||
var id, tool string
|
||||
_, err := pgx.ForEachRow(rows, []any{&id, &tool}, func() error {
|
||||
if _, ok := f.label[id]; !ok {
|
||||
f.label[id] = fmt.Sprintf("%s@%d", f.label[tool], f.action)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
|
||||
var lines []string
|
||||
rows = q(`select resource_well.id::text, supply from resource_well
|
||||
join staking_source on staking_source.id = source_id where user_id = $1`, user)
|
||||
var supply float64
|
||||
_, err = pgx.ForEachRow(rows, []any{&id, &supply}, func() error {
|
||||
lines = append(lines, fmt.Sprintf("well %s %s", f.lbl(id), fnum(supply)))
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
sort.Strings(lines)
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
|
||||
lines = nil
|
||||
rows = q(`select resource.name, balance from resource_account join resource on resource.id = resource_id
|
||||
where user_id = $1`, user)
|
||||
var name string
|
||||
var bal float64
|
||||
_, err = pgx.ForEachRow(rows, []any{&name, &bal}, func() error {
|
||||
lines = append(lines, fmt.Sprintf("balance %s %s", name, fnum(bal)))
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
sort.Strings(lines)
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
|
||||
var flags []string
|
||||
f.must(f.db.QueryRow(f.ctx, "select onboarding from users where id = $1", user).Scan(&flags))
|
||||
slices.Sort(flags)
|
||||
fmt.Fprintf(out, "onboarding %v\n", flags)
|
||||
|
||||
lines = nil
|
||||
rows = q(`select staking_event.id::text, well_id::text, inventory_item_id::text, duration_in_secs, stake_amount,
|
||||
staking_event.reach, claim_event.claim_amount,
|
||||
coalesce((select string_agg(cw.well_id::text || '=' || cw.amount::text, ',') from claim_well cw
|
||||
where cw.claim_event_id = claim_event.id), '')
|
||||
from staking_event left join claim_event on claim_event.staking_event_id = staking_event.id
|
||||
where staking_event.user_id = $1`, user)
|
||||
var well string
|
||||
var dur, amount float64
|
||||
var reach int
|
||||
var claim *float64
|
||||
var cw string
|
||||
_, err = pgx.ForEachRow(rows, []any{&id, &well, &tool, &dur, &amount, &reach, &claim, &cw}, func() error {
|
||||
c := "running"
|
||||
if claim != nil {
|
||||
c = "claimed " + fnum(*claim)
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range strings.Split(cw, ",") {
|
||||
if w, a, ok := strings.Cut(p, "="); ok {
|
||||
parts = append(parts, f.lbl(w)+"="+a)
|
||||
}
|
||||
}
|
||||
slices.Sort(parts)
|
||||
lines = append(lines, fmt.Sprintf("stake %s well %s tool %s secs %s amount %s reach %d %s %v",
|
||||
f.lbl(id), f.lbl(well), f.lbl(tool), fnum(dur), fnum(amount), reach, c, parts))
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
sort.Strings(lines)
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
|
||||
// The event log in order; runs of stake_stopped (ordered by start time only, ties
|
||||
// at random) sorted.
|
||||
lines = nil
|
||||
rows = q("select kind, data::text from event_log where user_id = $1 and id > $2 order by id", user, lastLog)
|
||||
var kind, data string
|
||||
var run []string
|
||||
flush := func() {
|
||||
slices.Sort(run)
|
||||
lines = append(lines, run...)
|
||||
run = nil
|
||||
}
|
||||
_, err = pgx.ForEachRow(rows, []any{&kind, &data}, func() error {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &m); err != nil {
|
||||
return err
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
var b strings.Builder
|
||||
for _, k := range keys {
|
||||
v := m[k]
|
||||
if s, ok := v.(string); ok {
|
||||
v = f.lbl(s)
|
||||
} else if x, ok := v.(float64); ok {
|
||||
v = fnum(x)
|
||||
}
|
||||
fmt.Fprintf(&b, " %s=%v", k, v)
|
||||
}
|
||||
line := "log " + kind + b.String()
|
||||
if kind == "stake_stopped" {
|
||||
run = append(run, line)
|
||||
return nil
|
||||
}
|
||||
flush()
|
||||
lines = append(lines, line)
|
||||
return nil
|
||||
})
|
||||
f.must(err)
|
||||
flush()
|
||||
if len(lines) > 0 {
|
||||
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// withConfig runs fn with the store items' reach replaced (item id → reach).
|
||||
func withReach(reach map[int]int, fn func()) {
|
||||
old := cfg()
|
||||
c := *old
|
||||
c.StoreItems = slices.Clone(old.StoreItems)
|
||||
for i := range c.StoreItems {
|
||||
if r, ok := reach[c.StoreItems[i].ID]; ok {
|
||||
c.StoreItems[i].Reach = r
|
||||
}
|
||||
}
|
||||
effective.Store(&c)
|
||||
defer effective.Store(old)
|
||||
fn()
|
||||
}
|
||||
|
||||
// seqStakeIDs makes new staking_event ids come from a sequence, in the order the stakes
|
||||
// are made, whether the database makes them (the column default) or the server
|
||||
// (newStakeID): stakes started together are ordered by id, which is otherwise random.
|
||||
func seqStakeIDs(t *testing.T, db *pgxpool.Pool) {
|
||||
ctx := context.Background()
|
||||
admin, err := pgxpool.New(ctx, adminDSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Close()
|
||||
const id = `('00000000-0000-4000-8000-' || lpad(to_hex(nextval('claim_test_seq')), 12, '0'))::uuid`
|
||||
for _, q := range []string{
|
||||
"create sequence if not exists claim_test_seq",
|
||||
"grant usage, select on sequence claim_test_seq to " + pgx.Identifier{env("DB_USER", "moonminers")}.Sanitize(),
|
||||
"alter table staking_event alter column id set default " + id,
|
||||
} {
|
||||
if _, err := admin.Exec(ctx, q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
oldID := newStakeID
|
||||
newStakeID = func() string {
|
||||
var s string
|
||||
if err := db.QueryRow(ctx, "select "+id+"::text").Scan(&s); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
newStakeID = oldID
|
||||
admin, err := pgxpool.New(ctx, adminDSN())
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec(ctx, "alter table staking_event alter column id set default gen_random_uuid()"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaimGolden(t *testing.T) {
|
||||
db, _ := testDB(t)
|
||||
seqStakeIDs(t, db)
|
||||
ctx := context.Background()
|
||||
// Live events for the redeployed stakes' amounts and durations; removed at the end.
|
||||
var evs []int
|
||||
for _, e := range []struct {
|
||||
kind, res string
|
||||
mult float64
|
||||
}{{"cycle_multiplier", "all", 1.5}, {"yield_multiplier", "Sollux", 1.25}, {"yield_multiplier", "Azurium", 0.8}} {
|
||||
var id int
|
||||
err := db.QueryRow(ctx, `insert into live_event(name, kind, params, starts_at, ends_at)
|
||||
values ('claim test', $1, $2, now() - interval '1 day', now() + interval '1 day') returning id`,
|
||||
e.kind, map[string]any{"resource": e.res, "multiplier": e.mult}).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evs = append(evs, id)
|
||||
}
|
||||
defer db.Exec(ctx, "delete from live_event where id = any($1)", evs)
|
||||
|
||||
var out strings.Builder
|
||||
run := func(cfgName string) {
|
||||
f := newFx(t, db)
|
||||
claimScenarios(f, &out, cfgName)
|
||||
}
|
||||
run("reach5")
|
||||
withReach(map[int]int{1: 1, 2: 1, 3: 3, 4: 2}, func() { run("mixed") })
|
||||
withReach(map[int]int{1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1}, func() { run("reach1") })
|
||||
|
||||
got := out.String()
|
||||
const path = "testdata/claim_golden.txt"
|
||||
if os.Getenv("MM_CLAIM_GOLDEN_WRITE") == "1" {
|
||||
if err := os.MkdirAll("testdata", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != string(want) {
|
||||
os.WriteFile(os.TempDir()+"/claim_golden.got", []byte(got), 0o644)
|
||||
gl, wl := strings.Split(got, "\n"), strings.Split(string(want), "\n")
|
||||
for i := 0; i < min(len(gl), len(wl)); i++ {
|
||||
if gl[i] != wl[i] {
|
||||
t.Fatalf("differs at line %d (full output in %s/claim_golden.got):\n got: %s\nwant: %s",
|
||||
i+1, os.TempDir(), gl[i], wl[i])
|
||||
}
|
||||
}
|
||||
t.Fatalf("differs in length: got %d lines, want %d", len(gl), len(wl))
|
||||
}
|
||||
}
|
||||
|
||||
// claimScenarios plays every scenario for one config; p prefixes the labels.
|
||||
func claimScenarios(f *fx, out *strings.Builder, p string) {
|
||||
all := ClaimScope{}
|
||||
redeploy := ClaimScope{Redeploy: true}
|
||||
|
||||
// 1. One-well drills overdrilling a well: the third claim empties it and stops the
|
||||
// fourth (ready, later in the list) and a running one; a multi-well drill on the
|
||||
// emptied well; claim-all with redeploy, then the redeployed drills again.
|
||||
u := f.user(p+"-overdrill", nil)
|
||||
w := f.moon(u, p+"-m1", []int{3, 0, 4}, []float64{10, 50, 5})
|
||||
for i, item := range []int{1, 2, 1, 3} {
|
||||
f.stake(u, fmt.Sprintf("%s-s%d", p, i), f.tool(u, fmt.Sprintf("%s-t%d", p, i), item, 0, 0), w[0], 4, 1, true)
|
||||
}
|
||||
f.stake(u, p+"-run", f.tool(u, p+"-t4", 1, 0, 0), w[0], 3, 1, false)
|
||||
f.stake(u, p+"-wide", f.tool(u, p+"-t5", 5, 1, 0), w[2], 7, 5, true)
|
||||
f.stake(u, p+"-az", f.tool(u, p+"-t6", 2, 0, 1), w[1], 9, 1, true)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy")
|
||||
f.finish(u)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy 2")
|
||||
f.finish(u)
|
||||
f.claim(out, u, all, "claim-all")
|
||||
|
||||
// 2. Multi-well drills on a 7-well moon (reach 5 covers part of it) and a 4-well moon,
|
||||
// wells emptied by multi-well claims stopping one-well drills; a moon that finishes.
|
||||
u = f.user(p+"-wide", []string{"market"}, "Ada Voss", "Rook Halvorsen")
|
||||
w7 := f.moon(u, p+"-m7", []int{0, 1, 2, 3, 4, 0, 1}, []float64{3, 8, 0, 12, 2.5, 6, 1})
|
||||
w4 := f.moon(u, p+"-m4", []int{4, 3, 2, 1}, []float64{2, 2, 1, 0.5})
|
||||
n := 0
|
||||
tl := func(item, yl, cl int) string {
|
||||
n++
|
||||
return f.tool(u, fmt.Sprintf("%s-w%d", p, n), item, yl, cl)
|
||||
}
|
||||
f.stake(u, p+"-a1", tl(4, 0, 0), w7[0], 9, 5, true)
|
||||
f.stake(u, p+"-a2", tl(1, 0, 0), w7[3], 5, 1, true)
|
||||
f.stake(u, p+"-a3", tl(5, 2, 1), w7[6], 6, 5, true)
|
||||
f.stake(u, p+"-a4", tl(1, 1, 0), w7[1], 2, 1, true)
|
||||
f.stake(u, p+"-a5", tl(3, 0, 0), w7[2], 4, 3, true)
|
||||
f.stake(u, p+"-a6", tl(1, 0, 0), w7[4], 3, 1, false)
|
||||
f.stake(u, p+"-a7", tl(6, 0, 0), w7[5], 20, 5, false)
|
||||
f.stake(u, p+"-b1", tl(2, 0, 0), w4[0], 1.5, 1, true)
|
||||
f.stake(u, p+"-b2", tl(7, 0, 0), w4[1], 3, 5, true)
|
||||
f.stake(u, p+"-b3", tl(1, 0, 0), w4[3], 1, 1, true)
|
||||
f.stake(u, p+"-b4", tl(4, 0, 0), w4[2], 2, 3, true)
|
||||
m7 := f.moonOf(w7[0])
|
||||
f.claim(out, u, ClaimScope{MoonID: m7}, "claim-moon m7")
|
||||
f.claim(out, u, ClaimScope{MoonID: m7, Redeploy: true}, "claim-moon m7 redeploy")
|
||||
f.claim(out, u, ClaimScope{MoonID: f.moonOf(w4[0]), Wide: true, Redeploy: true}, "claim-wide m4 redeploy")
|
||||
f.claim(out, u, ClaimScope{WellID: w4[0], Redeploy: true}, "claim-well m4.0 redeploy")
|
||||
f.claim(out, u, ClaimScope{WellID: w4[3]}, "claim-well m4.3")
|
||||
f.finish(u)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy")
|
||||
f.finish(u)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy 2")
|
||||
f.finish(u)
|
||||
f.claim(out, u, all, "claim-all")
|
||||
f.claim(out, u, all, "claim-all nothing")
|
||||
|
||||
// 3. A tool with a second unclaimed stake: redeploying it fails the whole claim.
|
||||
u = f.user(p+"-busy", []string{"all"})
|
||||
w = f.moon(u, p+"-mb", []int{0, 1}, []float64{20, 20})
|
||||
t := f.tool(u, p+"-bt", 1, 0, 0)
|
||||
f.stake(u, p+"-bs1", t, w[0], 2, 1, true)
|
||||
f.stake(u, p+"-bs2", t, w[1], 2, 1, false)
|
||||
f.stake(u, p+"-bs3", f.tool(u, p+"-bt2", 2, 0, 0), w[1], 2, 1, true)
|
||||
f.claim(out, u, redeploy, "claim-all redeploy busy")
|
||||
f.claim(out, u, all, "claim-all busy")
|
||||
|
||||
// 4. Many moons and drills with tight wells, seeded: three rounds of claim-all with
|
||||
// redeploy, then one without.
|
||||
u = f.user(p+"-many", nil, "Juno Park")
|
||||
rnd := rand.New(rand.NewSource(7))
|
||||
var moons [][]string
|
||||
for m := 0; m < 25; m++ {
|
||||
k := 1 + rnd.Intn(7)
|
||||
res, sup := make([]int, k), make([]float64, k)
|
||||
for i := range res {
|
||||
res[i] = rnd.Intn(5)
|
||||
sup[i] = float64(rnd.Intn(40)) / 2
|
||||
if rnd.Intn(6) == 0 {
|
||||
sup[i] = 0
|
||||
}
|
||||
}
|
||||
moons = append(moons, f.moon(u, fmt.Sprintf("%s-n%d", p, m), res, sup))
|
||||
}
|
||||
for i := 0; i < 120; i++ {
|
||||
ws := moons[rnd.Intn(len(moons))]
|
||||
item := 1 + rnd.Intn(7)
|
||||
reach := 1
|
||||
if rnd.Intn(2) == 0 {
|
||||
reach = []int{2, 3, 5}[rnd.Intn(3)]
|
||||
}
|
||||
tool := f.tool(u, fmt.Sprintf("%s-x%03d", p, i), item, rnd.Intn(3), rnd.Intn(3))
|
||||
f.stake(u, fmt.Sprintf("%s-y%03d", p, i), tool, ws[rnd.Intn(len(ws))], float64(1+rnd.Intn(16))/2, reach, rnd.Intn(5) != 0)
|
||||
}
|
||||
for r := 0; r < 3; r++ {
|
||||
f.claim(out, u, redeploy, fmt.Sprintf("claim-all redeploy round %d", r))
|
||||
f.finish(u)
|
||||
}
|
||||
f.claim(out, u, all, "claim-all final")
|
||||
}
|
||||
|
||||
func (f *fx) moonOf(well string) string {
|
||||
return f.id("select source_id::text from resource_well where id = $1", well)
|
||||
}
|
||||
|
||||
// TestClaimAllPerf times claim-all for players with n ready drills, and counts its
|
||||
// statements: one whole-moon drill per moon of three wells, or (crowded) n drills on 25
|
||||
// moons, a third of them one-well drills, more than the wells hold.
|
||||
func TestClaimAllPerf(t *testing.T) {
|
||||
db, tr := testDB(t)
|
||||
f := newFx(t, db)
|
||||
type perfRun struct {
|
||||
n int
|
||||
redeploy bool
|
||||
crowded bool
|
||||
}
|
||||
runs := []perfRun{{50, false, false}, {50, true, false}, {200, false, false}, {200, true, false},
|
||||
{900, false, false}, {900, true, false}, {900, false, true}, {900, true, true}}
|
||||
for _, run := range runs {
|
||||
n, moons, supply := run.n, run.n, []float64{40, 30, 3}
|
||||
if run.crowded {
|
||||
moons, supply = 25, []float64{300, 200, 8}
|
||||
}
|
||||
u := f.user(fmt.Sprintf("perf-%d-%v-%v", n, run.redeploy, run.crowded), nil)
|
||||
for m := 0; m < moons; m++ {
|
||||
f.moon(u, fmt.Sprintf("p%d-%d", n, m), []int{m, m + 1, m + 2}, supply)
|
||||
}
|
||||
_, err := db.Exec(f.ctx, `
|
||||
insert into inventory_item(user_id, store_item_id) select $1, 1 + (g % 7) from generate_series(1, $2) g`, u, n)
|
||||
f.must(err)
|
||||
_, err = db.Exec(f.ctx, `
|
||||
with tools as (select id, row_number() over (order by id) r from inventory_item where user_id = $1),
|
||||
moons as (select id, row_number() over (order by name) r from staking_source where user_id = $1)
|
||||
insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
|
||||
select $1, (select id from resource_well where source_id = moons.id order by id offset tools.r % 3 limit 1),
|
||||
tools.id, 10, 5 + tools.r % 4, case when $4 and tools.r % 3 = 0 then 1 else 5 end,
|
||||
$2::timestamptz + tools.r * interval '1 second'
|
||||
from tools join moons on moons.r = (tools.r - 1) % $3 + 1`, u, fxBase, moons, run.crowded)
|
||||
f.must(err)
|
||||
tr.mu.Lock()
|
||||
tr.slow = nil
|
||||
tr.mu.Unlock()
|
||||
start, n0 := time.Now(), tr.n.Load()
|
||||
res, err := claimReady(f.ctx, db, u, ClaimScope{Redeploy: run.redeploy})
|
||||
f.must(err)
|
||||
t.Logf("drills %d redeploy %v crowded %v: claimed %d redeployed %d stopped %d in %v, %d statements",
|
||||
n, run.redeploy, run.crowded, res.Claimed, res.Redeployed, res.Stopped,
|
||||
time.Since(start).Round(time.Millisecond), tr.n.Load()-n0)
|
||||
for _, s := range tr.slow {
|
||||
t.Logf(" slow: %.200s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
+645
-45
@@ -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
@@ -15,6 +15,8 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -73,9 +75,84 @@ func floorDiv(a, b int64) int64 {
|
||||
return q
|
||||
}
|
||||
|
||||
// marketRateAt is the market price of a unit at a tick, before live events.
|
||||
// marketRateAt is the market price of a unit at a tick, before live events. Ticks in the
|
||||
// cached 24h window (marketSeries) are read from it; the current tick fills it.
|
||||
func marketRateAt(name string, tick int64) float64 {
|
||||
return cfg().unitPrice(name) * (1 + cfg().MarketVolatility*marketDrift(name, tick))
|
||||
c := cfg()
|
||||
if mc := marketCached.Load(); mc != nil && mc.cfg == c && tick <= mc.tick && tick > mc.tick-marketPoints {
|
||||
return mc.get(name).series[marketPoints-1-int(mc.tick-tick)]
|
||||
}
|
||||
if d := tick - marketTick(time.Now()); d >= -1 && d <= 1 {
|
||||
return marketSeriesAt(name, tick).rate
|
||||
}
|
||||
return rateAt(c, name, tick)
|
||||
}
|
||||
|
||||
func rateAt(c *GameConfig, name string, tick int64) float64 {
|
||||
return c.unitPrice(name) * (1 + c.MarketVolatility*marketDrift(name, tick))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ shared 24h series
|
||||
|
||||
// marketSeries is an item's market rate over the 24h up to a tick (marketPoints, oldest
|
||||
// first) and what the market shows of it. It's shared by every player: read it, never
|
||||
// modify it. Live price events aren't in it (they multiply the quote per request), so
|
||||
// they never make it stale.
|
||||
type marketSeries struct {
|
||||
series []float64
|
||||
rate, open, high, low, change float64
|
||||
}
|
||||
|
||||
// marketTickCache holds the series of one tick under one config snapshot (cfg() is a new
|
||||
// pointer whenever the economy editor or another instance changes the config).
|
||||
type marketTickCache struct {
|
||||
cfg *GameConfig
|
||||
tick int64
|
||||
items sync.Map // name → *marketSeries
|
||||
}
|
||||
|
||||
var marketCached atomic.Pointer[marketTickCache]
|
||||
|
||||
func (mc *marketTickCache) get(name string) *marketSeries {
|
||||
if v, ok := mc.items.Load(name); ok {
|
||||
return v.(*marketSeries)
|
||||
}
|
||||
v, _ := mc.items.LoadOrStore(name, newMarketSeries(mc.cfg, name, mc.tick))
|
||||
return v.(*marketSeries)
|
||||
}
|
||||
|
||||
// marketSeriesAt is an item's series up to tick, cached for the newest tick asked for.
|
||||
func marketSeriesAt(name string, tick int64) *marketSeries {
|
||||
c := cfg()
|
||||
mc := marketCached.Load()
|
||||
if mc == nil || mc.cfg != c || mc.tick < tick {
|
||||
next := &marketTickCache{cfg: c, tick: tick}
|
||||
if marketCached.CompareAndSwap(mc, next) {
|
||||
mc = next
|
||||
} else {
|
||||
mc = marketCached.Load()
|
||||
}
|
||||
}
|
||||
if mc == nil || mc.cfg != c || mc.tick != tick {
|
||||
return newMarketSeries(c, name, tick) // an older tick, or a racing config change
|
||||
}
|
||||
return mc.get(name)
|
||||
}
|
||||
|
||||
func newMarketSeries(c *GameConfig, name string, tick int64) *marketSeries {
|
||||
m := &marketSeries{series: make([]float64, marketPoints)}
|
||||
for i := range m.series {
|
||||
m.series[i] = rateAt(c, name, tick-int64(marketPoints-1-i))
|
||||
}
|
||||
m.rate, m.open = m.series[marketPoints-1], m.series[0]
|
||||
m.high, m.low = m.series[0], m.series[0]
|
||||
for _, p := range m.series {
|
||||
m.high, m.low = max(m.high, p), min(m.low, p)
|
||||
}
|
||||
if m.open > 0 {
|
||||
m.change = (m.rate/m.open - 1) * 100
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// marketRate is the market price of a unit at time t, before live events.
|
||||
@@ -250,10 +327,9 @@ type Quote struct {
|
||||
High float64 // 24h high/low of the market rate
|
||||
Low float64
|
||||
Value float64 // Balance × Price (at the quote)
|
||||
Spark string // SVG path, viewBox 0 0 100 32
|
||||
URL string
|
||||
Selected bool
|
||||
series []float64
|
||||
series []float64 // the shared 24h market rate (marketSeries): read only
|
||||
}
|
||||
|
||||
func (q Quote) Up() bool { return q.Change >= 0 }
|
||||
@@ -311,34 +387,12 @@ func marketSymbol(name string) string {
|
||||
return strings.ToUpper(name)
|
||||
}
|
||||
|
||||
// MarketChart is the selected quote's 24h chart, viewBox 0 0 ChartW ChartH.
|
||||
// MarketChart is the selected quote's 24h chart: its points (the quote's series) start at
|
||||
// Start, unix ms, Step ms apart.
|
||||
type MarketChart struct {
|
||||
Line, Area string
|
||||
Prices string // comma-separated, for the crosshair
|
||||
Start, Step int64 // unix ms of the first point, ms between points
|
||||
HighY, LowY float64
|
||||
Grid []GridLine
|
||||
Times []TimeLabel
|
||||
Start, Step int64
|
||||
}
|
||||
|
||||
type GridLine struct {
|
||||
Y float64 // in viewBox units
|
||||
Top float64 // the same, as a percent of the height
|
||||
Label string
|
||||
}
|
||||
|
||||
type TimeLabel struct {
|
||||
X float64 // percent
|
||||
Label string
|
||||
}
|
||||
|
||||
const (
|
||||
chartW = 600
|
||||
chartH = 220
|
||||
sparkW = 100
|
||||
sparkH = 32
|
||||
)
|
||||
|
||||
// marketPage builds the market from the rows the market section lists (resources and
|
||||
// refined items).
|
||||
func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
||||
@@ -347,23 +401,13 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
||||
for _, row := range rows {
|
||||
q := Quote{MarketRow: row, Boost: s.PriceBoosts.For(row.Resource), Advisor: s.Bonus().Sell,
|
||||
URL: View{Section: "market", Moon: v.Moon, Pick: row.Resource}.URL()}
|
||||
q.series = make([]float64, marketPoints)
|
||||
for i := range q.series {
|
||||
q.series[i] = marketRateAt(row.Resource, now-int64(marketPoints-1-i))
|
||||
}
|
||||
q.Rate, q.Open = q.series[marketPoints-1], q.series[0]
|
||||
q.High, q.Low = q.series[0], q.series[0]
|
||||
for _, p := range q.series {
|
||||
q.High, q.Low = max(q.High, p), min(q.Low, p)
|
||||
}
|
||||
if q.Open > 0 {
|
||||
q.Change = (q.Rate/q.Open - 1) * 100
|
||||
}
|
||||
m := marketSeriesAt(row.Resource, now)
|
||||
q.series = m.series
|
||||
q.Rate, q.Open, q.High, q.Low, q.Change = m.rate, m.open, m.high, m.low, m.change
|
||||
q.Pressure = s.Pressure[row.Resource]
|
||||
q.Factor = cfg().MarketPressure.Factor(q.Pressure)
|
||||
q.Price = q.Rate * q.Boost
|
||||
q.Value = q.Balance * q.Price
|
||||
q.Spark, _ = chartPaths(q.series, 3, sparkW, sparkH, q.Low, q.High)
|
||||
mp.Quotes = append(mp.Quotes, q)
|
||||
}
|
||||
for i := range mp.Quotes {
|
||||
@@ -387,62 +431,11 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
||||
}
|
||||
if q := mp.Selected; q != nil {
|
||||
q.Selected = true
|
||||
mp.Chart = s.marketChart(q, now)
|
||||
mp.Chart = marketChart(now)
|
||||
}
|
||||
return mp
|
||||
}
|
||||
|
||||
func (s *State) marketChart(q *Quote, now int64) MarketChart {
|
||||
// Pad the range so the line doesn't touch the edges.
|
||||
pad := max((q.High-q.Low)*0.12, q.Rate*0.005)
|
||||
lo, hi := q.Low-pad, q.High+pad
|
||||
c := MarketChart{Step: marketStep.Milliseconds(),
|
||||
Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
|
||||
c.Line, c.Area = chartPaths(q.series, 1, chartW, chartH, lo, hi)
|
||||
y := func(p float64) float64 { return chartH - (p-lo)/(hi-lo)*chartH }
|
||||
c.HighY, c.LowY = y(q.High), y(q.Low)
|
||||
prices := make([]string, len(q.series))
|
||||
for i, p := range q.series {
|
||||
prices[i] = strconv.FormatFloat(p, 'f', 4, 64)
|
||||
}
|
||||
c.Prices = strings.Join(prices, ",")
|
||||
for i := 1; i <= 3; i++ {
|
||||
p := lo + (hi-lo)*float64(i)/4
|
||||
c.Grid = append(c.Grid, GridLine{Y: y(p), Top: y(p) / chartH * 100, Label: formatCredits(p)})
|
||||
}
|
||||
for h := 0; h <= 24; h += 6 {
|
||||
label := fmt.Sprintf("−%dh", 24-h)
|
||||
if h == 24 {
|
||||
label = "Now"
|
||||
}
|
||||
c.Times = append(c.Times, TimeLabel{X: float64(h) / 24 * 100, Label: label})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// chartPaths returns an SVG line path through every nth point of series, scaled to
|
||||
// w×h between lo and hi, and the area under it.
|
||||
func chartPaths(series []float64, every int, w, h, lo, hi float64) (line, area string) {
|
||||
if len(series) < 2 {
|
||||
return "", ""
|
||||
}
|
||||
if hi <= lo {
|
||||
lo, hi = lo-1, hi+1
|
||||
}
|
||||
var b strings.Builder
|
||||
last := len(series) - 1
|
||||
for i := 0; i <= last; i += every {
|
||||
if i+every > last {
|
||||
i = last
|
||||
}
|
||||
x := float64(i) / float64(last) * w
|
||||
y := h - (series[i]-lo)/(hi-lo)*h
|
||||
cmd := "L"
|
||||
if i == 0 {
|
||||
cmd = "M"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%.1f %.1f", cmd, x, y)
|
||||
}
|
||||
line = b.String()
|
||||
return line, fmt.Sprintf("%sL%.1f %.1fL0 %.1fZ", line, w, h, h)
|
||||
func marketChart(now int64) MarketChart {
|
||||
return MarketChart{Step: marketStep.Milliseconds(), Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMarketCache: the cached series match the uncached rates, and a new config snapshot
|
||||
// (the economy editor's override) is picked up at once.
|
||||
func TestMarketCache(t *testing.T) {
|
||||
old := cfg()
|
||||
defer effective.Store(old)
|
||||
now := marketTick(time.Now())
|
||||
item := "Sollux"
|
||||
m := marketSeriesAt(item, now)
|
||||
for _, tick := range []int64{now, now - 1, now - marketPoints + 1} {
|
||||
if got, want := marketRateAt(item, tick), rateAt(old, item, tick); got != want {
|
||||
t.Errorf("tick %d: cached %v, computed %v", tick, got, want)
|
||||
}
|
||||
}
|
||||
if m.rate != rateAt(old, item, now) || m.open != rateAt(old, item, now-marketPoints+1) {
|
||||
t.Errorf("series ends %v %v", m.open, m.rate)
|
||||
}
|
||||
c := baseConfig()
|
||||
c.ResourcePrices[item] *= 2
|
||||
effective.Store(&c)
|
||||
if got, want := marketSeriesAt(item, now).rate, rateAt(&c, item, now); got != want || got == m.rate {
|
||||
t.Errorf("after a config change: %v, want %v (was %v)", got, want, m.rate)
|
||||
}
|
||||
if got := marketRateAt(item, now-5); got != rateAt(&c, item, now-5) {
|
||||
t.Errorf("after a config change, rate %v", got)
|
||||
}
|
||||
}
|
||||
@@ -605,3 +605,14 @@ func runMoonTraits() error {
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
// loadMoonDescription loads the description of one of the player's moons into s.Moons
|
||||
// (loadState doesn't read them: only the moon view shows one).
|
||||
func loadMoonDescription(ctx context.Context, q querier, s *State, moonID string) error {
|
||||
m := s.moon(moonID)
|
||||
if m == nil || m.Description != "" {
|
||||
return nil
|
||||
}
|
||||
return q.QueryRow(ctx, "select description from staking_source where id = $1 and user_id = $2",
|
||||
m.ID, s.UserID).Scan(&m.Description)
|
||||
}
|
||||
+2
-1
@@ -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);
|
||||
@@ -41,6 +41,7 @@ create table staking_source (
|
||||
constraint fk_user_staking_source foreign key(user_id)
|
||||
references users(id) on delete cascade
|
||||
);
|
||||
create index staking_source_user on staking_source (user_id);
|
||||
|
||||
create table resource_well (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
@@ -52,6 +53,7 @@ create table resource_well (
|
||||
constraint fk_rid_resource_well foreign key(resource_id)
|
||||
references resource(id)
|
||||
);
|
||||
create index resource_well_source on resource_well (source_id);
|
||||
|
||||
create table inventory_item (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
@@ -68,6 +70,7 @@ create table inventory_item (
|
||||
constraint fk_store_item_inventory_item foreign key(store_item_id)
|
||||
references store_item(id) on delete cascade
|
||||
);
|
||||
create index inventory_item_user on inventory_item (user_id);
|
||||
|
||||
create table upgrade_event (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
@@ -95,6 +98,9 @@ create table staking_event (
|
||||
constraint fk_iiid_staking_event foreign key(inventory_item_id)
|
||||
references inventory_item(id) on delete cascade
|
||||
);
|
||||
create index staking_event_user on staking_event (user_id, created_at);
|
||||
create index staking_event_well on staking_event (well_id);
|
||||
create index staking_event_tool on staking_event (inventory_item_id, created_at);
|
||||
|
||||
create table claim_event (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
|
||||
Vendored
+5187
File diff suppressed because it is too large.
Load diff
+22
-10
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user