Compare commits
54
Commits
4c04651c52
...
329b954e31
No files matched your search
+25
-5
@@ -160,6 +160,7 @@ end
|
||||
|
||||
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
|
||||
local running = {}
|
||||
local shaking = {} -- { node, t, rest = { p } } of the shakes running (M.shake)
|
||||
|
||||
local function now()
|
||||
return socket.gettime()
|
||||
@@ -208,6 +209,7 @@ end
|
||||
-- Drop every frame-driven tween (a screen going away).
|
||||
function M.clear()
|
||||
running = {}
|
||||
shaking = {}
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- fading content
|
||||
@@ -343,21 +345,39 @@ end
|
||||
|
||||
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
|
||||
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
|
||||
-- A shake that starts while another runs on the node replaces it and keeps its resting
|
||||
-- place (read mid-shake, the position is off by up to amp and would stay so).
|
||||
local SHAKE = { { 0, 0 }, { 0.2, -1 }, { 0.4, 5 / 6 }, { 0.6, -0.5 }, { 0.8, 1 / 3 }, { 1, 0 } }
|
||||
function M.shake(node, amp, dur, delay)
|
||||
if M.reduced then
|
||||
return
|
||||
end
|
||||
amp, dur = amp or 6, dur or 0.4
|
||||
local p
|
||||
return M.tween(node, dur, function(k, n)
|
||||
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay)
|
||||
local rest = {}
|
||||
local keep = {}
|
||||
for _, s in ipairs(shaking) do
|
||||
if not s.t.dead then
|
||||
local ok, same = pcall(function() return s.node == node end)
|
||||
if ok and same then
|
||||
M.cancel(s.t)
|
||||
rest = s.rest
|
||||
else
|
||||
keep[#keep + 1] = s
|
||||
end
|
||||
end
|
||||
end
|
||||
shaking = keep
|
||||
local t = M.tween(node, dur, function(k, n)
|
||||
rest.p = rest.p or gui.get_position(n) -- where it is when the shake starts (after a delay)
|
||||
local p = rest.p
|
||||
gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
|
||||
end, delay or 0, function()
|
||||
if p then
|
||||
pcall(gui.set_position, node, p)
|
||||
if rest.p then
|
||||
pcall(gui.set_position, node, rest.p)
|
||||
end
|
||||
end)
|
||||
shaking[#shaking + 1] = { node = node, t = t, rest = rest }
|
||||
return t
|
||||
end
|
||||
|
||||
-- 1D value noise (smooth), for shakes.
|
||||
|
||||
@@ -28,6 +28,16 @@ local M = {}
|
||||
|
||||
local EPS = 0.01
|
||||
|
||||
-- A copy of a caller's opts (the label leaves change their copy: one opts table can serve
|
||||
-- several labels).
|
||||
local function copy(opts)
|
||||
local s = {}
|
||||
for k, v in pairs(opts or {}) do
|
||||
s[k] = v
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
local function differs(a, b)
|
||||
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
|
||||
end
|
||||
@@ -111,7 +121,7 @@ function M.label(parent, str, style, h, opts)
|
||||
if ui.needs_rich(str) and not (type(style) == "table" and style.markup) then -- (symbols: ui.rich runs, a container whose y is the middle)
|
||||
local n, w = ui.rich(parent, str, style, 0, 0)
|
||||
gui.set_pivot(n, gui.PIVOT_W)
|
||||
local s = opts or {}
|
||||
local s = copy(opts)
|
||||
s.fit, s.height = nil, h
|
||||
return { node = n, size = false, style = s, measure = function() return w, h end }
|
||||
end
|
||||
@@ -124,7 +134,7 @@ function M.label(parent, str, style, h, opts)
|
||||
w = w - ui.text_width(n)
|
||||
gui.set_text(n, t)
|
||||
end
|
||||
local s = opts or {}
|
||||
local s = copy(opts)
|
||||
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||
return { node = n, size = false, fit = fit, shrink = shrink, style = s, measure = function() return w, h end }
|
||||
@@ -139,7 +149,7 @@ function M.para(parent, str, style, lh, opts)
|
||||
local one = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), "Ag")
|
||||
local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
|
||||
gui.set_leading(n, lh / line)
|
||||
local s = opts or {}
|
||||
local s = copy(opts)
|
||||
local lines = s.lines
|
||||
s.lines = nil
|
||||
local measure = M.text_measure(n, lh)
|
||||
@@ -223,7 +233,7 @@ function M.made(style, make, kids) return M.box(style, kids, { make = make }) en
|
||||
-- A leaf for a text node that already exists (a template's part): pivot W, its middle at
|
||||
-- the box's middle, h tall, as wide as its text (set the text first).
|
||||
function M.node_label(node, h, opts)
|
||||
local s = opts or {}
|
||||
local s = copy(opts)
|
||||
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||
return { node = node, size = false, fit = fit, shrink = shrink, style = s, measure = function() return ui.text_width(node), h end }
|
||||
|
||||
@@ -24,7 +24,7 @@ local M = {}
|
||||
|
||||
local entries = {} -- { key, node, kind, label, zone, scope }
|
||||
local scope = "page"
|
||||
local seen = {} -- key -> count in this build (duplicate ids get "#n")
|
||||
local seen = {} -- base -> entries with it (duplicate ids get "#n")
|
||||
|
||||
local function alive(node)
|
||||
return pcall(gui.get_id, node)
|
||||
@@ -67,8 +67,30 @@ function M.add(id, node, kind, label, zone)
|
||||
return
|
||||
end
|
||||
local base = scope .. ":" .. (id or ("label:" .. tostring(label)))
|
||||
local key = base
|
||||
if (seen[base] or 0) > 0 then
|
||||
-- A row made again (a long list scrolled back, a search, a kept block replayed)
|
||||
-- leaves its deleted node's entry behind: drop those, so the live row keeps the
|
||||
-- plain key that list_step and find look for.
|
||||
local keep, taken = {}, {}
|
||||
for _, e in ipairs(entries) do
|
||||
if e.base ~= base then
|
||||
keep[#keep + 1] = e
|
||||
elseif alive(e.node) then
|
||||
keep[#keep + 1] = e
|
||||
taken[e.key] = true
|
||||
else
|
||||
seen[base] = seen[base] - 1
|
||||
end
|
||||
end
|
||||
entries = keep
|
||||
local n = 1
|
||||
while taken[key] do
|
||||
n = n + 1
|
||||
key = base .. "#" .. n
|
||||
end
|
||||
end
|
||||
seen[base] = (seen[base] or 0) + 1
|
||||
local key = seen[base] > 1 and (base .. "#" .. seen[base]) or base
|
||||
entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
|
||||
end
|
||||
|
||||
|
||||
@@ -73,9 +73,12 @@ local function node_rect(node)
|
||||
gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
|
||||
local br = gui.get_screen_position(probe)
|
||||
gui.delete_node(probe)
|
||||
local x0, x1 = math.floor(tl.x + 0.5), math.floor(br.x + 0.5)
|
||||
local y0, y1 = math.floor(br.y + 0.5), math.floor(tl.y + 0.5)
|
||||
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x)
|
||||
-- The size is rounded once (not as the difference of two rounded corners), so it stays
|
||||
-- the same while the node moves by fractions of a pixel: every new size remakes the
|
||||
-- render targets.
|
||||
local w, h = math.floor(br.x - tl.x + 0.5), math.floor(tl.y - br.y + 0.5)
|
||||
local x0, y0 = math.floor(tl.x + 0.5), math.floor(br.y + 0.5)
|
||||
return { x = x0, y = y0, w = w, h = h }, w / math.max(1, size.x)
|
||||
end
|
||||
|
||||
local function set_node(node, quiet)
|
||||
|
||||
@@ -623,7 +623,14 @@ M.register("claim", function(st, p, ctx)
|
||||
for _, s in ipairs(list) do
|
||||
local tool = M.tool(st, s.toolId)
|
||||
local w = wells(st, s.wellId)[1]
|
||||
local group = tool and w and st.well and st.well.id == w.id and find(st.well.idle, tool.storeItemId, "storeItemId")
|
||||
local group = nil
|
||||
if tool and w and st.well and st.well.id == w.id then
|
||||
for _, g in ipairs(st.well.idle or {}) do
|
||||
if same_copy(g, tool) then
|
||||
group = g
|
||||
end
|
||||
end
|
||||
end
|
||||
local amount = tool and w and (group and group.yield or tool.yield * yield_boost(st, w.resource))
|
||||
-- Not on a drained or fully committed well (the server leaves the tool idle).
|
||||
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, 1) or 1) > EPS then
|
||||
@@ -713,16 +720,22 @@ M.register("redrill", function(st, p, ctx)
|
||||
if left <= EPS then
|
||||
break
|
||||
end
|
||||
local pick
|
||||
-- What each takes a claim, with the well's yield event (the server's t.Amount).
|
||||
local amounts = {}
|
||||
for i, t in ipairs(tools) do
|
||||
if t.yield >= left - EPS and (not pick or t.yield < tools[pick].yield) then
|
||||
local g = group_of(t)
|
||||
amounts[i] = g and g.yield or t.yield * yield_boost(st, w.resource)
|
||||
end
|
||||
local pick
|
||||
for i, a in ipairs(amounts) do
|
||||
if a >= left - EPS and (not pick or a < amounts[pick]) then
|
||||
pick = i
|
||||
end
|
||||
end
|
||||
if not pick then
|
||||
pick = 1
|
||||
for i, t in ipairs(tools) do
|
||||
if t.yield > tools[pick].yield then
|
||||
for i, a in ipairs(amounts) do
|
||||
if a > amounts[pick] then
|
||||
pick = i
|
||||
end
|
||||
end
|
||||
|
||||
+13
-8
@@ -198,10 +198,11 @@ local function visible(node)
|
||||
return true
|
||||
end
|
||||
|
||||
-- A run of commands spread over frames: { frame_fn, ... }.
|
||||
local function steps(g, list, done)
|
||||
-- A run of commands spread over frames: { frame_fn, ... }. id: the command's, for the
|
||||
-- error reply when a step fails.
|
||||
local function steps(g, list, done, id)
|
||||
g.test_steps = g.test_steps or {}
|
||||
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done }
|
||||
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done, id = id }
|
||||
end
|
||||
|
||||
-- mm.test_dir (drive.py passes its output folder): replies go to files there.
|
||||
@@ -343,7 +344,7 @@ function COMMANDS.click(g, cmd, input)
|
||||
}
|
||||
steps(g, list, function()
|
||||
reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
|
||||
end)
|
||||
end, cmd.id)
|
||||
end
|
||||
|
||||
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds.
|
||||
@@ -385,7 +386,7 @@ function COMMANDS.key(g, cmd, input)
|
||||
function() input(k, { released = true }) end,
|
||||
}, function()
|
||||
reply(cmd.id, { ok = true })
|
||||
end)
|
||||
end, cmd.id)
|
||||
end
|
||||
|
||||
function COMMANDS.type(g, cmd, input)
|
||||
@@ -605,7 +606,7 @@ function COMMANDS.dump(g, cmd)
|
||||
steps(g, { function() end, function()
|
||||
local ok, d = pcall(dump, g)
|
||||
reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) })
|
||||
end })
|
||||
end }, nil, cmd.id)
|
||||
end
|
||||
|
||||
function COMMANDS.state(g)
|
||||
@@ -692,9 +693,13 @@ function M.update(g, input, api)
|
||||
-- Running multi-frame steps.
|
||||
if g.test_steps and #g.test_steps > 0 then
|
||||
local s = g.test_steps[1]
|
||||
s.list[s.i]()
|
||||
local ok, err = pcall(s.list[s.i])
|
||||
s.i = s.i + 1
|
||||
if s.i > #s.list then
|
||||
if not ok then
|
||||
-- (a failing step ends its run: reply with the error, or the command never answers)
|
||||
table.remove(g.test_steps, 1)
|
||||
reply(s.id, { ok = false, error = tostring(err) })
|
||||
elseif s.i > #s.list then
|
||||
table.remove(g.test_steps, 1)
|
||||
if s.done then
|
||||
s.done()
|
||||
|
||||
@@ -274,8 +274,8 @@ function M.fit_text(node, max_w)
|
||||
return
|
||||
end
|
||||
while #text > 1 do
|
||||
-- (a cut entity like "&am" would show as text)
|
||||
text = text:sub(1, -2):gsub("&[%a#]*$", ""):gsub("%s+$", "")
|
||||
-- (a whole UTF-8 character at a time; a cut entity like "&am" would show as text)
|
||||
text = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", ""):gsub("&[%a#]*$", ""):gsub("%s+$", "")
|
||||
gui.set_text(node, text .. "…")
|
||||
if M.text_width(node) <= max_w then
|
||||
return
|
||||
|
||||
@@ -12,6 +12,7 @@ local moon_view = require("modules.moon.moon")
|
||||
local reveal = require("screens.game.reveal")
|
||||
local moons = require("screens.game.moons")
|
||||
local moon_sale = require("screens.game.moon_sale")
|
||||
local predict = require("modules.predict")
|
||||
local panel = require("components.panel")
|
||||
local scroll = require("components.scroll")
|
||||
local bar = require("components.bar")
|
||||
@@ -266,9 +267,26 @@ local function well_panel(g, root, pad, top, inner, H)
|
||||
local wide = moon.wide
|
||||
local wide_here = {}
|
||||
if wide and #(wrow.wide or {}) > 0 then
|
||||
-- The server's here on these is the whole moon (what it gets in all): this well's
|
||||
-- share comes from the projection (the take from this well).
|
||||
local p = predict.project(st, moon)
|
||||
local wi
|
||||
for i, x in ipairs(moon.wells or {}) do
|
||||
if x.id == w.id then
|
||||
wi = i
|
||||
end
|
||||
end
|
||||
for _, d in ipairs(wide.drills or {}) do
|
||||
if contains(wrow.wide, d.id) then
|
||||
wide_here[#wide_here + 1] = d
|
||||
local dd = {}
|
||||
for k, v in pairs(d) do
|
||||
dd[k] = v
|
||||
end
|
||||
local take = p.take[d.id]
|
||||
if take and wi then
|
||||
dd.here = take[wi] or 0
|
||||
end
|
||||
wide_here[#wide_here + 1] = dd
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,8 +103,8 @@ local STEPS = {
|
||||
function(a) return a.g.view.section == "new-moon" and shot("11-new-moon") end,
|
||||
function(a) return a:press("buy-moon") end,
|
||||
function(a) return a.g.reveal:card_done() and shot("12-reveal") end,
|
||||
-- Buy another, straight from the card (its button registers as buy-moon).
|
||||
function(a) return a:press("buy-moon") end,
|
||||
-- Buy another, straight from the card (its button registers as reveal-buy).
|
||||
function(a) return a:press("reveal-buy") end,
|
||||
function(a) return #a.g.state.moons > 2 and a.g.reveal:card_done() and shot("12-reveal-another") end,
|
||||
function(a) return a:press("reveal-close") end,
|
||||
function(a) return not a.g.reveal:active() and #a.g.state.moons > 1 and shot("12-second-moon") end,
|
||||
|
||||
@@ -109,13 +109,9 @@ local STEPS = {
|
||||
function(a) return a.g.view.section == "market" and shot("10-market-leaderboard") end,
|
||||
function(a) return go(a, "help") end,
|
||||
function(a) return a.g.view.section == "help" and shot("11-help") end,
|
||||
-- Start over: arm the button (its first click), then run new-game.
|
||||
function(a)
|
||||
a.g.help_armed = a.g.h.now(a.g) + 30000
|
||||
a.g.dirty = true
|
||||
return true
|
||||
end,
|
||||
function() return shot("12-help-confirm") end,
|
||||
-- Start over: open its confirm, then run new-game (as its OK).
|
||||
function(a) return a:press("confirm:new-game") end,
|
||||
function(a) return a.g.dialog.root and shot("12-help-confirm") end,
|
||||
function(a) return a:press("new-game") end,
|
||||
function(a) return a.g.state.view.section ~= "help" or true end,
|
||||
function() return shot("13-new-game") end,
|
||||
|
||||
@@ -13,6 +13,7 @@ local button = require("components.button")
|
||||
local chip = require("components.chip")
|
||||
local stat = require("components.stat")
|
||||
local fg = require("modules.flex_gui")
|
||||
local blocks = require("modules.blocks")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
@@ -31,11 +32,12 @@ function M.compact(n)
|
||||
local function one(f)
|
||||
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
|
||||
end
|
||||
if a >= 1e6 then
|
||||
-- The unit is picked after rounding (999500 is 1M, not 1000k; 999.6 is 1k).
|
||||
if a >= 1e6 - 500 then
|
||||
return one(n / 1e6) .. "M"
|
||||
elseif a >= 1e4 then
|
||||
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k"
|
||||
elseif a >= 1e3 then
|
||||
elseif a >= 1e3 - 0.5 then
|
||||
return one(n / 1e3) .. "k"
|
||||
end
|
||||
return ui.money(math.floor(n + 0.5))
|
||||
@@ -260,6 +262,30 @@ function M.confirm_server_button(self, b, c, act, params, id)
|
||||
end
|
||||
end })
|
||||
self.fx:button(id, b)
|
||||
-- Autoplay presses `act` as if confirmed: it runs the action (through the dialog when
|
||||
-- its confirm is open), and presses `id` to open the confirm.
|
||||
if self.autoplay then
|
||||
local function confirmed()
|
||||
local d = self.dialog
|
||||
if d.root and not d.closing and d.opts and d.opts.ok and d.opts.ok.act == act then
|
||||
d:act(act, params)
|
||||
else
|
||||
self.fx:origin(b, id)
|
||||
self.autoplay.fns.act(self, act, params or {}) -- (session.act)
|
||||
end
|
||||
end
|
||||
local function ask()
|
||||
M.confirm_server(self, c, act, params, opener(self, b, id))
|
||||
end
|
||||
local function register()
|
||||
if self.autoplay then
|
||||
self.autoplay:register(act, params, confirmed)
|
||||
self.autoplay:register(id, params, ask)
|
||||
end
|
||||
end
|
||||
register()
|
||||
blocks.note(register) -- (a kept block registers again)
|
||||
end
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- inline confirm
|
||||
|
||||
@@ -168,10 +168,10 @@ function M.chart(parent, x, y, w, h, data, opts)
|
||||
local tip_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
|
||||
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
|
||||
gui.set_enabled(cross, false)
|
||||
local rect
|
||||
local shown = -1
|
||||
local function move(action)
|
||||
rect = rect or screen_rect(box, cw, ch)
|
||||
-- Measured on every move: scrolling or the entrance slide moves the box.
|
||||
local rect = screen_rect(box, cw, ch)
|
||||
local inside = action.screen_x >= rect.x0 and action.screen_x <= rect.x1
|
||||
and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
|
||||
if not inside then
|
||||
|
||||
@@ -113,9 +113,11 @@ end
|
||||
-- spinner and ignores clicks until the server answers.
|
||||
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. hit: a node to
|
||||
-- take the clicks instead of b (a button whose visuals glide while its spot stays put).
|
||||
-- With params a function, pass id ("act:<action>?<its fixed params>"): it is also the
|
||||
-- busy key, so each such button has its own (else they all share the action's name).
|
||||
-- Returns the click function (a text field's Enter runs it too).
|
||||
function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
|
||||
local key = button_key(name, params)
|
||||
local key = type(params) == "function" and id and (id:gsub("^act:", "")) or button_key(name, params)
|
||||
if g.inflight[key] then
|
||||
button.set_busy(b)
|
||||
end
|
||||
|
||||
@@ -128,6 +128,9 @@ local function suspend(g)
|
||||
g.refetch = nil
|
||||
end
|
||||
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {}
|
||||
if g.history then
|
||||
g.history.want = nil -- (its request was in the outbox)
|
||||
end
|
||||
g.boot = { text = SUSPENDED }
|
||||
if g.dialog and g.dialog:is_open() then
|
||||
g.dialog:close("cancel")
|
||||
@@ -215,7 +218,9 @@ end
|
||||
|
||||
-- The views shown, like the web's browser history (main.js syncURL: every response whose
|
||||
-- view differs from the current entry pushes one; back and forward fetch an entry again
|
||||
-- without pushing). g.history = { list = { view, ... }, i = current }.
|
||||
-- without pushing). g.history = { list = { view, ... }, i = current, want = the index a
|
||||
-- back/forward on its way goes to }. i moves when that answer comes; until then answers
|
||||
-- to requests sent before it (an action, a refetch) push nothing.
|
||||
local VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
|
||||
|
||||
local function view_key(v)
|
||||
@@ -242,7 +247,14 @@ local function remember(g, from_history)
|
||||
if from_history then
|
||||
if h.list[from_history] then
|
||||
h.list[from_history] = v
|
||||
h.i = from_history
|
||||
end
|
||||
if h.want == from_history then
|
||||
h.want = nil
|
||||
end
|
||||
return
|
||||
end
|
||||
if h.want then
|
||||
return
|
||||
end
|
||||
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then
|
||||
@@ -290,6 +302,10 @@ pump = function(g)
|
||||
elseif g.base then
|
||||
show(g) -- rolled back
|
||||
end
|
||||
-- (a back/forward that failed: the history stays where it was)
|
||||
if not applied and r.nav and r.nav.from_history and history(g).want == r.nav.from_history then
|
||||
history(g).want = nil
|
||||
end
|
||||
if status == 403 then
|
||||
suspend(g)
|
||||
g.inflight, g.busy, g.nav_view = {}, false, nil
|
||||
@@ -414,12 +430,13 @@ end
|
||||
-- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
|
||||
function M.go(g, dir)
|
||||
local h = history(g)
|
||||
local v = h.list[h.i + dir]
|
||||
local i = (h.want or h.i) + dir
|
||||
local v = h.list[i]
|
||||
if not v then
|
||||
return false
|
||||
end
|
||||
h.i = h.i + dir
|
||||
local to = { from_history = h.i }
|
||||
h.want = i
|
||||
local to = { from_history = i }
|
||||
for k, x in pairs(v) do
|
||||
to[k] = x
|
||||
end
|
||||
|
||||
@@ -627,7 +627,7 @@ local function build_market(self, root, px, py, w, h, H, stacked, full_w)
|
||||
if not empty then
|
||||
sell_now = H.action(self, b1, n1, "sell", function()
|
||||
return { resource = q.item, amount = f.text, pick = q.item }
|
||||
end, clear)
|
||||
end, clear, nil, "act:sell?pick=" .. q.item .. "&resource=" .. q.item)
|
||||
end
|
||||
end, { width = half }),
|
||||
fg.button(c, "Sell all", { disabled = empty }, function(b2, n2)
|
||||
|
||||
@@ -213,17 +213,21 @@ local function save(g)
|
||||
show_status(L)
|
||||
local sent = clone(L.draft)
|
||||
api.save_moon_settings(sent, function(err)
|
||||
-- The game's moons take the saved settings even if the lab was left meanwhile.
|
||||
if err then
|
||||
g.toasts:push("Moon settings not saved: " .. err, "error", true)
|
||||
else
|
||||
moon_view.settings = clone(sent)
|
||||
g.toasts:push("Moon settings saved: the game's moons use them now", "save-code", false)
|
||||
end
|
||||
if g.lab ~= L then
|
||||
return
|
||||
end
|
||||
L.saving = false
|
||||
if err then
|
||||
L.error = "Save failed: " .. err
|
||||
g.toasts:push("Moon settings not saved: " .. err, "error", true)
|
||||
else
|
||||
L.saved = sent
|
||||
moon_view.settings = clone(sent)
|
||||
g.toasts:push("Moon settings saved: the game's moons use them now", "save-code", false)
|
||||
end
|
||||
show_status(L)
|
||||
end)
|
||||
|
||||
@@ -572,7 +572,7 @@ local function recipe_card(self, H, c, st, rf, r)
|
||||
refine = H.action(self, b, nodes, "refine", function()
|
||||
local q = self.refine_qty and self.refine_qty[r.item] or "1"
|
||||
return { recipe = r.item, qty = (q ~= "" and q or "1") }
|
||||
end)
|
||||
end, nil, nil, "act:refine?recipe=" .. r.item)
|
||||
tooltip.add(b, "Refine the number in the box")
|
||||
-- Enter in the quantity refines (the web's form).
|
||||
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
|
||||
|
||||
@@ -516,6 +516,9 @@ function M:bind()
|
||||
if r.card then
|
||||
if g.autoplay then
|
||||
g.autoplay:register("reveal-close", {}, function() self:close() end)
|
||||
if r.another_btn and r.another_ok then
|
||||
g.autoplay:register("reveal-buy", {}, function() self:buy_another() end)
|
||||
end
|
||||
end
|
||||
if r.another_btn and not r.another_ok then
|
||||
tooltip.add(r.another_btn, r.another_why)
|
||||
|
||||
@@ -63,11 +63,14 @@ def read(name, default=None):
|
||||
|
||||
|
||||
def alive(pid):
|
||||
# Our game or Xvfb still: the pid file outlives a game that exits on its own, and its
|
||||
# number can be reused by another process (stop would kill that one's group).
|
||||
try:
|
||||
os.kill(int(pid), 0)
|
||||
return True
|
||||
with open("/proc/%d/cmdline" % int(pid), "rb") as f:
|
||||
cmd = f.read()
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
return any(n in cmd for n in (b"dmengine", b"MoonMiners", b"Xvfb"))
|
||||
|
||||
|
||||
def snapshot():
|
||||
|
||||
+15
-10
@@ -11,18 +11,19 @@ Usage:
|
||||
scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
|
||||
with run(game, args).
|
||||
Options:
|
||||
--api URL server (default http://localhost:3000; MM_API env)
|
||||
--api URL server (or MM_API; required, no default: a test server, never :3000)
|
||||
--size WxH window and display size (default 1280x800)
|
||||
--out DIR screenshots and game.log (default dist/drive/<scenario>)
|
||||
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default
|
||||
--keep don't stop the game at the end (prints its pid and port)
|
||||
--keep don't stop the game, or its Xvfb, at the end (prints their pids, the
|
||||
port and the display)
|
||||
--attach PORT don't start a game: drive the one whose engine service is on PORT
|
||||
(`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
|
||||
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
|
||||
--log FILE the attached game's log (default dist/dev/game.log)
|
||||
--config K=V extra engine config (mm.player=..., repeatable)
|
||||
--fixture make a test player first (scripts/fixture.py against --api, which must
|
||||
then be given, or MM_API) and play as it; the scenario gets its ids as game.fixture
|
||||
--fixture make a test player first (scripts/fixture.py against --api) and play as
|
||||
it; the scenario gets its ids as game.fixture
|
||||
--fixture-file F use the ids saved in F (a fixture.py output) instead
|
||||
|
||||
The game it starts saves its files (player ids, settings) under <out>/data (or
|
||||
@@ -381,7 +382,7 @@ def main():
|
||||
ap = argparse.ArgumentParser(add_help=True)
|
||||
ap.add_argument("scenario")
|
||||
ap.add_argument("rest", nargs="*")
|
||||
ap.add_argument("--api", default=os.environ.get("MM_API", "http://localhost:3000"))
|
||||
ap.add_argument("--api", default=os.environ.get("MM_API"))
|
||||
ap.add_argument("--size", default="1280x800")
|
||||
ap.add_argument("--out")
|
||||
ap.add_argument("--bundle", action="store_true")
|
||||
@@ -392,6 +393,9 @@ def main():
|
||||
ap.add_argument("--fixture", action="store_true")
|
||||
ap.add_argument("--fixture-file")
|
||||
args = ap.parse_args()
|
||||
if not args.api:
|
||||
# (No default: the game makes a player and plays on whatever server this is.)
|
||||
sys.exit("drive.py: pass --api or set MM_API to a test server (no default)")
|
||||
|
||||
if args.attach:
|
||||
disp = os.path.join(ROOT, "dist/dev/display")
|
||||
@@ -413,8 +417,12 @@ def main():
|
||||
try:
|
||||
code = subprocess.call([sys.executable] + sys.argv, env=env)
|
||||
finally:
|
||||
xvfb.terminate()
|
||||
xvfb.wait()
|
||||
if args.keep:
|
||||
# The kept game draws on this display: leave it running too.
|
||||
print("Xvfb pid %d, display :%s" % (xvfb.pid, num))
|
||||
else:
|
||||
xvfb.terminate()
|
||||
xvfb.wait()
|
||||
sys.exit(code)
|
||||
|
||||
path = find_scenario(args.scenario)
|
||||
@@ -428,9 +436,6 @@ def main():
|
||||
if not args.attach:
|
||||
args.config.append("mm.player=" + fixture["player"])
|
||||
elif args.fixture:
|
||||
if "--api" not in sys.argv and not os.environ.get("MM_API"):
|
||||
sys.exit("drive.py: --fixture creates a player on the server: pass --api or set MM_API "
|
||||
"to a test server (no default)")
|
||||
fixture = json.loads(subprocess.check_output([sys.executable, os.path.join(HERE, "fixture.py"), args.api]))
|
||||
args.config.append("mm.player=" + fixture["player"])
|
||||
print("fixture player", fixture["player"])
|
||||
|
||||
@@ -34,7 +34,7 @@ def run(game, args):
|
||||
clicks = [
|
||||
("moons", "act:claim?claim-well"),
|
||||
("refinery", "act:collect?job"),
|
||||
("market", "act:sell"),
|
||||
("market", "act:sell?pick"),
|
||||
("store", "act:buy-tool?id"),
|
||||
("equipment", "act:upgrade?tool"),
|
||||
]
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
# any build (no test hook needed), so an old bundle can be compared with a new one:
|
||||
# scripts/views.sh dist/baseline/MoonMiners/MoonMiners.x86_64 dist/compare/before fx.json
|
||||
# scripts/views.sh dist/x86_64-linux/MoonMiners/MoonMiners.x86_64 dist/compare/after fx.json
|
||||
# API base: MM_API (default http://localhost:3000).
|
||||
# API base: MM_API (required: a test server, no default).
|
||||
set -euo pipefail
|
||||
bin=$1; out=$2; fx=$3; size=${4:-1280x800}
|
||||
api=${MM_API:-http://localhost:3000}
|
||||
api=${MM_API:?views.sh: set MM_API to a test server (no default)}
|
||||
get() { python3 -c "import json,sys;print(json.load(open('$fx'))['$1'])"; }
|
||||
player=$(get player); moon=$(get moon); well=$(get well); drill=$(get drill_mining)
|
||||
settle=4; [ "$size" != 1280x800 ] && settle=15
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ services:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: moonminers
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
|
||||
|
||||
+6
-3
@@ -22,6 +22,8 @@ die() { echo "wt: $*" >&2; exit 1; }
|
||||
task_vars() {
|
||||
TASK=${1:-}
|
||||
[[ $TASK =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "task name: lowercase letters, digits and -"
|
||||
# mm_<task> must fit Postgres's 63-byte names (longer ones are cut short silently).
|
||||
((${#TASK} <= 60)) || die "task name: at most 60 characters"
|
||||
WT=$WS/wt-$TASK
|
||||
DB=mm_${TASK//-/_}
|
||||
ST=$WT/.wt
|
||||
@@ -63,7 +65,8 @@ start_server() {
|
||||
{ tail -5 "$ST/migrate.log"; die "migrate failed (log $ST/migrate.log)"; }
|
||||
rm -f "$ST/pid"
|
||||
# Its own session (not killed with the caller's), pid written by the process itself.
|
||||
(cd "$WT/server" && DB_DATABASE=$DB PORT=$port MM_DEV=1 PIDFILE=$ST/pid \
|
||||
# On 127.0.0.1 only: MM_DEV=1 has no admin login.
|
||||
(cd "$WT/server" && DB_DATABASE=$DB HOST=127.0.0.1 PORT=$port MM_DEV=1 PIDFILE=$ST/pid \
|
||||
setsid -f bash -c 'echo $$ >"$PIDFILE"; exec bin/moonminers serve' \
|
||||
>"$ST/server.log" 2>&1 </dev/null)
|
||||
for _ in $(seq 25); do [[ -s $ST/pid ]] && break; sleep 0.1; done
|
||||
@@ -126,7 +129,7 @@ up() {
|
||||
start_server
|
||||
fi
|
||||
echo "worktree $WT"
|
||||
echo "MM_API=http://localhost:$(cat "$ST/port")"
|
||||
echo "MM_API=http://127.0.0.1:$(cat "$ST/port")"
|
||||
echo "stop: $0 down $TASK (or rm $TASK to also remove the worktree)"
|
||||
}
|
||||
|
||||
@@ -179,7 +182,7 @@ case ${1:-} in
|
||||
up) up "${2:-}" ;;
|
||||
down) down "${2:-}" ;;
|
||||
rm) remove "${2:-}" ;;
|
||||
restart) task_vars "${2:-}"; stop_server; start_server; echo "MM_API=http://localhost:$(cat "$ST/port")" ;;
|
||||
restart) task_vars "${2:-}"; stop_server; start_server; echo "MM_API=http://127.0.0.1:$(cat "$ST/port")" ;;
|
||||
status) status ;;
|
||||
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
|
||||
esac
|
||||
+2
-1
@@ -29,7 +29,7 @@ every command below from this directory (`server/`).
|
||||
- `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
|
||||
limits with forms parsed up front, a per-IP limit on new players), `runAction` (every game
|
||||
limits with forms parsed up front, a per-IP limit on new players, IPv6 by its /64), `runAction` (every game
|
||||
action).
|
||||
- `admin.go`: the admin login (`ADMIN_PASSWORD`, `/utils/login`, `requireAdmin` around every
|
||||
other `/utils` route, in-memory sessions in the `mm_admin` cookie; none with `MM_DEV=1`).
|
||||
@@ -183,6 +183,7 @@ commit `.env`. `ADMIN_PASSWORD`: the admin pages' password (unset: `/utils` is l
|
||||
`ADMIN_USER_IDS` (comma-separated player ids): admins in the game API (`state.admin`,
|
||||
`admin-grant`, saving moon settings). With `MM_DEV=1` there is no admin login and every
|
||||
player is an admin.
|
||||
`PORT` (default 3000) and `HOST` (unset: every interface; `scripts/wt` sets `127.0.0.1`).
|
||||
|
||||
## Conventions
|
||||
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
|
||||
|
||||
+2
-1
@@ -38,7 +38,8 @@ ADMIN_DB_USER=postgres # superuser, only used by `migrate`
|
||||
ADMIN_DB_PASSWORD=postgres
|
||||
```
|
||||
|
||||
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `ADMIN_PASSWORD` (the
|
||||
Optional: `PORT` (default 3000), `HOST` (the address to listen on, e.g. `127.0.0.1`; unset:
|
||||
every interface), `DB_SSLMODE` (default `disable`), `ADMIN_PASSWORD` (the
|
||||
password of the admin pages, `/utils`; unset: they're locked), `ADMIN_USER_IDS`
|
||||
(comma-separated player ids that are admins in the game API: `state.admin`, `admin-grant`,
|
||||
saving moon settings), `MM_DEV=1` (reload templates and static files from disk; no admin
|
||||
|
||||
+13
-2
@@ -641,7 +641,15 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
|
||||
userID = c.Value
|
||||
}
|
||||
}
|
||||
if !uuidPattern.MatchString(userID) || !userExists(ctx, s.db, userID) {
|
||||
exists := false
|
||||
if uuidPattern.MatchString(userID) {
|
||||
var err error
|
||||
if exists, err = userExists(ctx, s.db, userID); err != nil {
|
||||
apiServerError(w, err)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
if !s.newPlayers.allow(r) {
|
||||
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
|
||||
return "", false
|
||||
@@ -652,7 +660,10 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
|
||||
return "", false
|
||||
}
|
||||
created = true
|
||||
} else if isBanned(ctx, s.db, userID) {
|
||||
} else if banned, err := isBanned(ctx, s.db, userID); err != nil {
|
||||
apiServerError(w, err)
|
||||
return "", false
|
||||
} else if banned {
|
||||
apiError(w, http.StatusForbidden, "This account is suspended")
|
||||
return "", false
|
||||
}
|
||||
|
||||
+3
-1
@@ -297,7 +297,9 @@ button), `credits` ("Need ₥X more", the button disabled) or `""`.
|
||||
|
||||
`{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId,
|
||||
moonName, wellId, resource, supply, credits, perMin, idle, reason}]}`: up to 3 wells to
|
||||
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first;
|
||||
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first; `credits`
|
||||
is a cycle's take after the drills already on the moon, and wells where it would get nothing
|
||||
(stake refused) are left out (`allEmpty` when none is left);
|
||||
`reason` `best-value`, `most-supply`, `no-drill` or `shared`, `reasonText` its text
|
||||
("Highest value").
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// For("all") counts the "all" event once (cycle events are always saved as "all").
|
||||
func TestPriceBoostsFor(t *testing.T) {
|
||||
b := PriceBoosts{"all": 2, "iron": 1.5}
|
||||
for name, want := range map[string]float64{"all": 2, "iron": 3, "gold": 2} {
|
||||
if got := b.For(name); got != want {
|
||||
t.Errorf("For(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
if got := (PriceBoosts{}).For("all"); got != 1 {
|
||||
t.Errorf("empty For(all) = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -122,6 +123,10 @@ type MoonType struct {
|
||||
|
||||
var game = mustParseGameConfig(gameJSON)
|
||||
|
||||
// resourceNames are the rows of the resource table (sql/data.sql). Wells are stored by
|
||||
// these names, so the config can price no other resource.
|
||||
var resourceNames = []string{"Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance"}
|
||||
|
||||
func mustParseGameConfig(data []byte) GameConfig {
|
||||
var cfg GameConfig
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
@@ -164,10 +169,16 @@ func (cfg *GameConfig) validate() error {
|
||||
return fmt.Errorf("equipmentResale.factor must be at least 0 and below 1")
|
||||
}
|
||||
for name, price := range cfg.ResourcePrices {
|
||||
if !slices.Contains(resourceNames, name) {
|
||||
return fmt.Errorf("resourcePrices: %q is not a resource (%s)", name, strings.Join(resourceNames, ", "))
|
||||
}
|
||||
if price < 0 {
|
||||
return fmt.Errorf("%s price is negative", name)
|
||||
}
|
||||
}
|
||||
if len(cfg.StoreItems) == 0 {
|
||||
return fmt.Errorf("no store items")
|
||||
}
|
||||
ids := map[int]bool{}
|
||||
for _, item := range cfg.StoreItems {
|
||||
if item.Price < 0 || item.ClaimAmount < 0 || item.CompletionTimeInSecs < 1 {
|
||||
|
||||
+29
-3
@@ -296,8 +296,9 @@ func watchOverride(ctx context.Context, db *pgxpool.Pool) {
|
||||
}
|
||||
|
||||
// saveOverride records a new override (by the given admin), re-syncs the store items and
|
||||
// puts it in effect.
|
||||
func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note, userID string) (int64, error) {
|
||||
// puts it in effect. With since >= 0 it refuses when the newest saved version is not since
|
||||
// (0: none), so a save from a page loaded before another save doesn't undo that save.
|
||||
func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note, userID string, since int64) (int64, error) {
|
||||
c, err := applyOverride(ov)
|
||||
if err != nil {
|
||||
return 0, gameErr("%s", err.Error())
|
||||
@@ -308,6 +309,19 @@ func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note
|
||||
}
|
||||
var id int64
|
||||
err = inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
if since >= 0 {
|
||||
// The lock makes two saves from the same version take turns.
|
||||
if _, err := tx.Exec(ctx, "lock table config_override in share row exclusive mode"); err != nil {
|
||||
return err
|
||||
}
|
||||
var latest int64
|
||||
if err := tx.QueryRow(ctx, "select coalesce(max(id), 0) from config_override").Scan(&latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if latest != since {
|
||||
return gameErr("Version #%d was saved after this page loaded. Reload the page and make your changes again.", latest)
|
||||
}
|
||||
}
|
||||
err := tx.QueryRow(ctx,
|
||||
`insert into config_override(data, note, created_by)
|
||||
values ($1, $2, (select id from users where id::text = $3)) returning id`,
|
||||
@@ -502,6 +516,12 @@ func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
// The newest saved version (not always the one in effect: an invalid one is skipped);
|
||||
// a save from this page is refused once there is a newer one.
|
||||
var latest int64
|
||||
if len(history) > 0 {
|
||||
latest = history[0].ID
|
||||
}
|
||||
base := baseConfig()
|
||||
c := cfg()
|
||||
quotes := marketQuotes(c, time.Now())
|
||||
@@ -509,6 +529,7 @@ func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
|
||||
"saved": configDoc(c),
|
||||
"base": configDoc(&base),
|
||||
"version": overrideVersion.Load(),
|
||||
"latest": latest,
|
||||
"market": quotes,
|
||||
"colors": resourceColors,
|
||||
}
|
||||
@@ -542,6 +563,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var ov ConfigOverride
|
||||
var note string
|
||||
since := int64(-1) // save: the version the page loaded with
|
||||
switch r.PathValue("action") {
|
||||
case "analyze":
|
||||
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
|
||||
@@ -568,6 +590,10 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
||||
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
|
||||
return
|
||||
}
|
||||
if since, err = strconv.ParseInt(r.PostFormValue("since"), 10, 64); err != nil || since < 0 {
|
||||
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"Reload the page: the save didn't say which version it started from"}})
|
||||
return
|
||||
}
|
||||
if diffConfig(cfg(), &draft).ChangeCount() == 0 {
|
||||
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"No changes to save"}})
|
||||
return
|
||||
@@ -595,7 +621,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id, err := saveOverride(ctx, s.db, ov, note, "")
|
||||
id, err := saveOverride(ctx, s.db, ov, note, "", since)
|
||||
if err != nil {
|
||||
if isGameError(err) {
|
||||
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
|
||||
|
||||
+45
-14
@@ -232,10 +232,12 @@ func toolReach(storeItemID int) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
func userExists(ctx context.Context, db *pgxpool.Pool, userID string) bool {
|
||||
// userExists reports whether the player is there; an error is not a no (apiUser must not
|
||||
// make a new player over a real one when the database is briefly unreachable).
|
||||
func userExists(ctx context.Context, db *pgxpool.Pool, userID string) (bool, error) {
|
||||
var exists bool
|
||||
err := db.QueryRow(ctx, "select exists(select 1 from users where id = $1)", userID).Scan(&exists)
|
||||
return err == nil && exists
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, error) {
|
||||
@@ -747,6 +749,11 @@ func buyMoon(ctx context.Context, db *pgxpool.Pool, userID, grade string) (strin
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// runAction checked the gate before the lock; a buy sent at the same time may have
|
||||
// made this one a second active moon before the store.
|
||||
if err := newMoonAllowed(ctx, tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
var owned int
|
||||
var last string
|
||||
err = tx.QueryRow(ctx, `
|
||||
@@ -794,13 +801,16 @@ func buyMoon(ctx context.Context, db *pgxpool.Pool, userID, grade string) (strin
|
||||
wells := rollWells(cfg(), g, kind, owned == 0, mrand.Float64)
|
||||
rating := moonRating(g, wells)
|
||||
for _, w := range wells {
|
||||
_, err = tx.Exec(ctx, `
|
||||
tag, err := tx.Exec(ctx, `
|
||||
insert into resource_well(source_id, resource_id, supply)
|
||||
select $1, id, $3 from resource where name = $2`,
|
||||
moonID, w.Resource, w.Supply)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return fmt.Errorf("no resource %q for a well", w.Resource)
|
||||
}
|
||||
}
|
||||
// Describe the moon from its look and wells (moondesc.go).
|
||||
desc, err := moonDescription(ctx, tx, moonID, g.MeanSupply())
|
||||
@@ -853,6 +863,11 @@ func buyTool(ctx context.Context, db *pgxpool.Pool, userID string, storeItemID i
|
||||
// the page was stale), another idle tool of the same type goes instead.
|
||||
func stake(ctx context.Context, db *pgxpool.Pool, userID, toolID, wellID string) error {
|
||||
return inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
// The wells before the tool, as stakeInTx and claims take them: in the other
|
||||
// order a claim-and-redeploy on the same moon and tool deadlocks with this.
|
||||
if err := lockMoonWells(ctx, tx, userID, wellID); err != nil {
|
||||
return err
|
||||
}
|
||||
toolID, err := pickTool(ctx, tx, userID, toolID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -951,27 +966,32 @@ func moonInTx(ctx context.Context, tx pgx.Tx, userID, moonID string) (*Moon, []S
|
||||
return m, stakes, err
|
||||
}
|
||||
|
||||
func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) error {
|
||||
if !uuidPattern.MatchString(toolID) {
|
||||
return gameErr("Tool not found")
|
||||
}
|
||||
// lockMoonWells locks every well of the player's moon that has wellID, in id order.
|
||||
func lockMoonWells(ctx context.Context, tx pgx.Tx, userID, wellID string) error {
|
||||
if !uuidPattern.MatchString(wellID) {
|
||||
return gameErr("Well not found")
|
||||
}
|
||||
// Lock the moon's wells first, in id order (as claims do): what its drills will take
|
||||
// decides whether there is room for this one.
|
||||
_, err := tx.Exec(ctx, `
|
||||
select 1 from resource_well where source_id = (
|
||||
select resource_well.source_id from resource_well
|
||||
join staking_source on staking_source.id = resource_well.source_id
|
||||
where resource_well.id = $1 and staking_source.user_id = $2)
|
||||
order by id for update`, wellID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) error {
|
||||
if !uuidPattern.MatchString(toolID) {
|
||||
return gameErr("Tool not found")
|
||||
}
|
||||
// Lock the moon's wells first, in id order (as claims do): what its drills will take
|
||||
// decides whether there is room for this one.
|
||||
if err := lockMoonWells(ctx, tx, userID, wellID); err != nil {
|
||||
return err
|
||||
}
|
||||
var storeItemID int
|
||||
var lv Levels
|
||||
err = tx.QueryRow(ctx, `
|
||||
err := tx.QueryRow(ctx, `
|
||||
select store_item_id, yield_level, cycle_level from inventory_item
|
||||
where id = $1 and user_id = $2
|
||||
for update`, toolID, userID).Scan(&storeItemID, &lv.Yield, &lv.Cycle)
|
||||
@@ -1214,6 +1234,10 @@ func redrill(ctx context.Context, db *pgxpool.Pool, userID, wellID string) (int,
|
||||
}
|
||||
var n int
|
||||
err := inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
// The wells before the tools, the order stakeInTx and claims take them in.
|
||||
if err := lockMoonWells(ctx, tx, userID, wellID); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
select inventory_item.id, store_item_id, yield_level, cycle_level from inventory_item
|
||||
join lateral (
|
||||
@@ -1290,16 +1314,23 @@ func redrill(ctx context.Context, db *pgxpool.Pool, userID, wellID string) (int,
|
||||
}
|
||||
if left <= 1e-9 {
|
||||
// This well is full: drills that mine several wells may still have work on
|
||||
// the moon (stakeInTx decides), in order, as before.
|
||||
// the moon (stakeInTx decides), in order, each tried; the rest stay idle.
|
||||
var full error
|
||||
for _, t := range tools {
|
||||
err := stakeInTx(ctx, tx, userID, t.ID, wellID)
|
||||
if (err == errWellCommitted || err == errWellEmpty) && n > 0 {
|
||||
break
|
||||
if err == errWellCommitted || err == errWellEmpty {
|
||||
if full == nil {
|
||||
full = err
|
||||
}
|
||||
continue
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
n++
|
||||
}
|
||||
if n == 0 && full != nil {
|
||||
return full
|
||||
}
|
||||
break
|
||||
}
|
||||
amounts := make([]float64, len(tools))
|
||||
|
||||
+5
-4
@@ -241,7 +241,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
||||
var advisors []string
|
||||
seats := 1
|
||||
refinery := c.Refinery.UnlockGrade == ""
|
||||
grade := 0 // of the last moon bought
|
||||
grade := 0 // the dearest moon bought: purchases are valued on it, and its price is kept
|
||||
pressure := map[string]float64{}
|
||||
stock := map[string]float64{}
|
||||
var resources []string
|
||||
@@ -394,7 +394,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
||||
freeOre += m.free
|
||||
open = append(open, len(moons))
|
||||
moons = append(moons, m)
|
||||
grade = g
|
||||
grade = max(grade, g)
|
||||
if _, ok := res.FirstGrade[gr.Grade]; !ok {
|
||||
res.FirstGrade[gr.Grade] = SimTime(t)
|
||||
res.GradeClicks[gr.Grade] = clicks
|
||||
@@ -583,10 +583,11 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
||||
wantBuy = false
|
||||
for range 50 {
|
||||
b := best("", pl.All)
|
||||
// Keep enough for the next moon, unless the drills have plenty of ore.
|
||||
// Keep enough for the next moon, unless the drills have plenty of ore: the dearest
|
||||
// grade bought (a grade picked from credits would fall with every purchase).
|
||||
reserve := 0.0
|
||||
if freeOre < 20*need() {
|
||||
reserve = c.MoonGrades[nextGrade(income)].Price
|
||||
reserve = c.MoonGrades[grade].Price
|
||||
}
|
||||
if b.Kind == "" || credits-b.Cost < reserve {
|
||||
break
|
||||
|
||||
+13
-10
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@@ -27,9 +28,10 @@ func logEvent(ctx context.Context, q querier, userID, kind string, data map[stri
|
||||
return err
|
||||
}
|
||||
|
||||
// Credits spent are logged as "price" on these kinds, earned as "credits" on sell.
|
||||
// Credits spent are logged as "price" on spendKinds, earned as "credits" on earnKinds.
|
||||
// buy_attachment: attachments were sold until migration 020 (kept for older events).
|
||||
const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'buy_attachment', 'hire_advisor', 'buy_seat'"
|
||||
const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'upgrade_batch', 'buy_attachment', 'hire_advisor', 'buy_seat'"
|
||||
const earnKinds = "'sell', 'sell_moon', 'sell_tool'"
|
||||
|
||||
// ------------------------------------------------------------------ dashboard
|
||||
|
||||
@@ -94,7 +96,7 @@ func loadDashboard(ctx context.Context, q querier) (*Dashboard, error) {
|
||||
where created_at >= day and created_at < day + 1 and kind not in ('session', 'new_player')
|
||||
and kind not like 'admin\_%'),
|
||||
(select coalesce(sum((data->>'credits')::float8), 0) from event_log
|
||||
where created_at >= day and created_at < day + 1 and kind = 'sell'),
|
||||
where created_at >= day and created_at < day + 1 and kind in (`+earnKinds+`)),
|
||||
(select coalesce(sum((data->>'price')::float8), 0) from event_log
|
||||
where created_at >= day and created_at < day + 1 and kind in (`+spendKinds+`))
|
||||
from days order by day`)
|
||||
@@ -125,20 +127,21 @@ func loadDashboard(ctx context.Context, q querier) (*Dashboard, error) {
|
||||
d.ActiveToday = d.Days[n-1].Active
|
||||
}
|
||||
|
||||
// Dn retention: of the players who joined in the last 30 days at least n days ago,
|
||||
// the share that did something n days after the day they joined.
|
||||
// Dn retention: of the players who joined in the last 30 days whose day n is over
|
||||
// (joined at least n+1 days ago), the share that did something n days after the day
|
||||
// they joined.
|
||||
err = q.QueryRow(ctx, `
|
||||
with cohort as (
|
||||
select id, created_at::date as day from users
|
||||
where created_at >= current_date - 30
|
||||
)
|
||||
select
|
||||
count(*) filter (where day <= current_date - 1),
|
||||
count(*) filter (where day <= current_date - 1 and exists (
|
||||
count(*) filter (where day <= current_date - 2),
|
||||
count(*) filter (where day <= current_date - 2 and exists (
|
||||
select 1 from event_log where user_id = cohort.id
|
||||
and created_at >= day + 1 and created_at < day + 2 and kind not like 'admin\_%')),
|
||||
count(*) filter (where day <= current_date - 7),
|
||||
count(*) filter (where day <= current_date - 7 and exists (
|
||||
count(*) filter (where day <= current_date - 8),
|
||||
count(*) filter (where day <= current_date - 8 and exists (
|
||||
select 1 from event_log where user_id = cohort.id
|
||||
and created_at >= day + 7 and created_at < day + 8 and kind not like 'admin\_%'))
|
||||
from cohort`).Scan(&d.D1.Cohort, &d.D1.Returned, &d.D7.Cohort, &d.D7.Returned)
|
||||
@@ -362,7 +365,7 @@ func parseAnnouncement(r *http.Request) (Announcement, string) {
|
||||
switch {
|
||||
case a.Message == "":
|
||||
msg = "Write a message"
|
||||
case len(a.Message) > 200:
|
||||
case utf8.RuneCountInString(a.Message) > 200: // as the form's maxlength counts
|
||||
msg = "Keep the message under 200 characters"
|
||||
case !slices.Contains(announcementLevels, a.Level):
|
||||
msg = "Pick a level"
|
||||
|
||||
+3
-2
@@ -189,13 +189,14 @@ func loadPressure(ctx context.Context, q querier, s *State) error {
|
||||
// PriceBoosts are the active price_multiplier events: by resource name, and "all".
|
||||
type PriceBoosts map[string]float64
|
||||
|
||||
// For is the combined multiplier for a resource (same rule as eventMultiplier).
|
||||
// For is the combined multiplier for a resource (same rule as eventMultiplier); For("all")
|
||||
// is the "all" events alone.
|
||||
func (b PriceBoosts) For(name string) float64 {
|
||||
m := 1.0
|
||||
if v, ok := b["all"]; ok {
|
||||
m *= v
|
||||
}
|
||||
if v, ok := b[name]; ok {
|
||||
if v, ok := b[name]; ok && name != "all" {
|
||||
m *= v
|
||||
}
|
||||
return m
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// An event on every resource counts once, also when asked for "all" (the cycle speed).
|
||||
func TestPriceBoostsAllCountsOnce(t *testing.T) {
|
||||
b := PriceBoosts{"all": 2, "Azurium": 3}
|
||||
if got := b.For("all"); got != 2 {
|
||||
t.Errorf(`For("all") = %v, want 2`, got)
|
||||
}
|
||||
if got := b.For("Azurium"); got != 6 {
|
||||
t.Errorf(`For("Azurium") = %v, want 6`, got)
|
||||
}
|
||||
if got := b.For("Sollux"); got != 2 {
|
||||
t.Errorf(`For("Sollux") = %v, want 2`, got)
|
||||
}
|
||||
}
|
||||
+32
-22
@@ -144,39 +144,49 @@ func actionAllowed(ctx context.Context, db *pgxpool.Pool, userID, action string,
|
||||
if feature == "" {
|
||||
return nil
|
||||
}
|
||||
if feature == "new-moon" {
|
||||
return newMoonAllowed(ctx, db, userID)
|
||||
}
|
||||
s := &State{}
|
||||
err := db.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&s.Onboarding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if feature == "new-moon" {
|
||||
// The first moon is always for sale (and the next once every moon is mined out,
|
||||
// see moonFinished), more come with the store.
|
||||
var active int
|
||||
err := db.QueryRow(ctx, `
|
||||
select count(*) from staking_source
|
||||
where user_id = $1
|
||||
and (exists (select 1 from resource_well where source_id = staking_source.id and supply > 0)
|
||||
or exists (
|
||||
select 1 from staking_event
|
||||
join resource_well on resource_well.id = staking_event.well_id
|
||||
where resource_well.source_id = staking_source.id
|
||||
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)))`,
|
||||
userID).Scan(&active)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if active == 0 {
|
||||
return nil
|
||||
}
|
||||
feature = "store"
|
||||
}
|
||||
if !s.Unlocked(feature) {
|
||||
return gameErr("Not available yet")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newMoonAllowed is the new-moon gate: the first moon is always for sale (and the next once
|
||||
// every moon is mined out, see moonFinished), more come with the store. buyMoon checks it
|
||||
// again under the player's row lock, so two buys at once can't both pass it.
|
||||
func newMoonAllowed(ctx context.Context, q querier, userID string) error {
|
||||
s := &State{}
|
||||
err := q.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&s.Onboarding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var active int
|
||||
err = q.QueryRow(ctx, `
|
||||
select count(*) from staking_source
|
||||
where user_id = $1
|
||||
and (exists (select 1 from resource_well where source_id = staking_source.id and supply > 0)
|
||||
or exists (
|
||||
select 1 from staking_event
|
||||
join resource_well on resource_well.id = staking_event.well_id
|
||||
where resource_well.source_id = staking_source.id
|
||||
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)))`,
|
||||
userID).Scan(&active)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if active > 0 && !s.Unlocked("store") {
|
||||
return gameErr("Not available yet")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminOnboarding resets a player's onboarding (reset) or unlocks everything.
|
||||
func adminOnboarding(ctx context.Context, db *pgxpool.Pool, adminID, userID string, reset bool) error {
|
||||
return inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
|
||||
+9
-3
@@ -35,10 +35,14 @@ func (s *server) playerRoutes(mux *http.ServeMux) {
|
||||
|
||||
// ------------------------------------------------------------------ bans
|
||||
|
||||
func isBanned(ctx context.Context, db *pgxpool.Pool, userID string) bool {
|
||||
// isBanned reports whether the player is banned (no such player: not banned).
|
||||
func isBanned(ctx context.Context, db *pgxpool.Pool, userID string) (bool, error) {
|
||||
var banned bool
|
||||
err := db.QueryRow(ctx, "select banned_at is not null from users where id = $1", userID).Scan(&banned)
|
||||
return err == nil && banned
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return banned, err
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ list
|
||||
@@ -151,7 +155,9 @@ func (s *server) handlePlayers(w http.ResponseWriter, r *http.Request) {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
// htmx's search swaps just the list; a history restore (Back past htmx's cache) asks
|
||||
// with HX-Request too but wants the whole page.
|
||||
if r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-History-Restore-Request") != "true" {
|
||||
s.render(w, "pl-list", p)
|
||||
return
|
||||
}
|
||||
|
||||
+28
-15
@@ -394,7 +394,7 @@ func simTimeline(saved, draft *GameConfig, same bool) *Timeline {
|
||||
|
||||
// catChart is a small chart of one value per category (drill tiers, grades, levels), saved
|
||||
// against the draft. Labels name the draft's categories.
|
||||
func catChart(title, ylabel string, logY bool, labels []string, saved, draft []float64, yf func(float64) string, tip func(i int, v float64) string, draftName string) *Chart {
|
||||
func catChart(title, ylabel string, logY bool, labels []string, saved, draft []float64, yf func(float64) string, tip func(k, i int, v float64) string, draftName string) *Chart {
|
||||
ch := &Chart{Title: title, YLabel: ylabel, LogY: logY, YFmt: yf, Width: 360, Height: 200,
|
||||
XFmt: func(v float64) string {
|
||||
if i := int(v) - 1; i >= 0 && i < len(labels) && float64(i+1) == v {
|
||||
@@ -408,7 +408,7 @@ func catChart(title, ylabel string, logY bool, labels []string, saved, draft []f
|
||||
for k, vals := range [][]float64{saved, draft} {
|
||||
s := ChartSeries{Name: []string{"Saved", draftName}[k], Class: []string{savedClass, draftClass}[k]}
|
||||
for i, v := range vals {
|
||||
s.Points = append(s.Points, ChartPoint{X: float64(i + 1), Y: v, Title: s.Name + " · " + tip(i, v)})
|
||||
s.Points = append(s.Points, ChartPoint{X: float64(i + 1), Y: v, Title: s.Name + " · " + tip(k, i, v)})
|
||||
}
|
||||
ch.Series = append(ch.Series, s)
|
||||
}
|
||||
@@ -450,9 +450,10 @@ func curves(saved, draft *GameConfig, ls, ld Ladder, draftName string) (tiers, g
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Each series' points are named by their own config's tiers (k: 0 saved, 1 draft).
|
||||
tierChart := func(title, ylabel string, logY bool, f func(tier) float64, yf func(float64) string) *Chart {
|
||||
return catChart(title, ylabel, logY, labels, pick(ts, f), pick(td, f), yf,
|
||||
func(i int, v float64) string { return fmt.Sprintf("%s: %s", labels[min(i, len(labels)-1)], yf(v)) }, draftName)
|
||||
func(k, i int, v float64) string { return fmt.Sprintf("%s: %s", [][]tier{ts, td}[k][i].code, yf(v)) }, draftName)
|
||||
}
|
||||
num := func(v float64) string { return formatCompact(v)[len(currency):] }
|
||||
tiers = []*Chart{
|
||||
@@ -462,9 +463,20 @@ func curves(saved, draft *GameConfig, ls, ld Ladder, draftName string) (tiers, g
|
||||
tierChart("Units per second", "on a moon of its grade", true, func(t tier) float64 { return t.perSec }, num),
|
||||
}
|
||||
|
||||
var glabels []string
|
||||
for _, g := range draft.MoonGrades {
|
||||
glabels = append(glabels, g.Grade)
|
||||
gradesOf := func(c *GameConfig) []string {
|
||||
var out []string
|
||||
for _, g := range c.MoonGrades {
|
||||
out = append(out, g.Grade)
|
||||
}
|
||||
return out
|
||||
}
|
||||
glabels := gradesOf(draft)
|
||||
gnames := [][]string{gradesOf(saved), glabels} // per series, as the ladders'
|
||||
gname := func(k, i int) string {
|
||||
if i < len(gnames[k]) {
|
||||
return gnames[k][i]
|
||||
}
|
||||
return fmt.Sprint(i + 1)
|
||||
}
|
||||
gvals := func(l Ladder, f func(GradeStat) float64) []float64 {
|
||||
var out []float64
|
||||
@@ -473,8 +485,8 @@ func curves(saved, draft *GameConfig, ls, ld Ladder, draftName string) (tiers, g
|
||||
}
|
||||
return out
|
||||
}
|
||||
gtip := func(i int, v float64) string {
|
||||
return fmt.Sprintf("%s: %s", glabels[min(i, len(glabels)-1)], formatCompact(v))
|
||||
gtip := func(k, i int, v float64) string {
|
||||
return fmt.Sprintf("%s: %s", gname(k, i), formatCompact(v))
|
||||
}
|
||||
grades = []*Chart{
|
||||
catChart("Moon price", "₥", true, glabels, gvals(ls, func(g GradeStat) float64 { return g.Price }),
|
||||
@@ -483,9 +495,10 @@ func curves(saved, draft *GameConfig, ls, ld Ladder, draftName string) (tiers, g
|
||||
gvals(ld, func(g GradeStat) float64 { return g.Mean * g.Price }), formatCompact, gtip, draftName),
|
||||
catChart("Value ÷ price", "× (p10 to p90 in the tooltip)", false, glabels, gvals(ls, func(g GradeStat) float64 { return g.Mean }),
|
||||
gvals(ld, func(g GradeStat) float64 { return g.Mean }), func(v float64) string { return fmt.Sprintf("%.2g×", v) },
|
||||
func(i int, v float64) string {
|
||||
if i < len(ld.Stats) {
|
||||
return fmt.Sprintf("%s: %.2f× (draft p10 %.2f×, p90 %.2f×)", glabels[min(i, len(glabels)-1)], v, ld.Stats[i].P10, ld.Stats[i].P90)
|
||||
func(k, i int, v float64) string {
|
||||
st := []Ladder{ls, ld}[k].Stats
|
||||
if i < len(st) {
|
||||
return fmt.Sprintf("%s: %.2f× (p10 %.2f×, p90 %.2f×)", gname(k, i), v, st[i].P10, st[i].P90)
|
||||
}
|
||||
return fmt.Sprintf("%.2f×", v)
|
||||
}, draftName),
|
||||
@@ -505,14 +518,14 @@ func curves(saved, draft *GameConfig, ls, ld Ladder, draftName string) (tiers, g
|
||||
}
|
||||
for _, kind := range trackKinds {
|
||||
for _, t := range td {
|
||||
vs, _ := levelVals(ls, t.code, kind)
|
||||
vd, steps := levelVals(ld, t.code, kind)
|
||||
vs, ss := levelVals(ls, t.code, kind)
|
||||
vd, sd := levelVals(ld, t.code, kind)
|
||||
var lab []string
|
||||
for i := range max(len(vs), len(vd)) {
|
||||
lab = append(lab, fmt.Sprint(i+1))
|
||||
}
|
||||
ch := catChart(t.code+" "+kind, "min to pay back", true, lab, vs, vd, minutesText, func(i int, v float64) string {
|
||||
if i < len(steps) {
|
||||
ch := catChart(t.code+" "+kind, "min to pay back", true, lab, vs, vd, minutesText, func(k, i int, v float64) string {
|
||||
if steps := [][]Purchase{ss, sd}[k]; i < len(steps) {
|
||||
st := steps[i]
|
||||
return fmt.Sprintf("level %d: %s for +%s/min, pays back in %s", i+1, formatCredits(st.Cost), formatCompact(st.GainPerMin), minutesText(v))
|
||||
}
|
||||
|
||||
@@ -109,3 +109,31 @@ func TestPayLadderJumps(t *testing.T) {
|
||||
}
|
||||
t.Fatal("drill missing")
|
||||
}
|
||||
|
||||
// The tier charts name each series' points from that series' own config: a draft with no
|
||||
// drills used to index an empty label list, and a new cheapest drill shifted the saved names.
|
||||
func TestCurvesTierNames(t *testing.T) {
|
||||
saved := baseConfig()
|
||||
empty := baseConfig()
|
||||
empty.StoreItems, empty.StartingTools = nil, nil
|
||||
progression(&saved, &empty, false) // must not panic
|
||||
|
||||
cheap := baseConfig()
|
||||
extra := cheap.StoreItems[0]
|
||||
extra.Name, extra.Price = "Test Zzpick", 1
|
||||
cheap.StoreItems = append([]StoreItem{extra}, cheap.StoreItems...)
|
||||
tiers, _, _ := curves(&saved, &cheap, payLadder(&saved), payLadder(&cheap), "Draft")
|
||||
low := saved.StoreItems[0]
|
||||
for _, it := range saved.StoreItems {
|
||||
if it.Price < low.Price {
|
||||
low = it
|
||||
}
|
||||
}
|
||||
first := toolCode(low.Name)
|
||||
if s := tiers[0].Series[0]; !strings.Contains(s.Points[0].Title, first+":") {
|
||||
t.Errorf("saved point 1 is %q, want the saved cheapest drill %s", s.Points[0].Title, first)
|
||||
}
|
||||
if d := tiers[0].Series[1]; !strings.Contains(d.Points[0].Title, toolCode(extra.Name)+":") {
|
||||
t.Errorf("draft point 1 is %q, want %s", d.Points[0].Title, toolCode(extra.Name))
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -199,7 +199,11 @@ func (p *Page) moonReveal() *MoonReveal {
|
||||
rare, rareShare = w, share
|
||||
}
|
||||
}
|
||||
if rare.Resource != "" && rareShare <= 0.15 {
|
||||
switch {
|
||||
case rare.Resource == "" || rareShare > 0.15:
|
||||
case rareShare == 0: // bigger than every sampled well
|
||||
r.Traits = append(r.Traits, fmt.Sprintf("Rare: %s-rich · under 1 in %d moons", rare.Resource, len(odds.values)))
|
||||
default:
|
||||
r.Traits = append(r.Traits, fmt.Sprintf("Rare: %s-rich · 1 in %d moons", rare.Resource, oneIn(rareShare)))
|
||||
}
|
||||
return r
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A well bigger than every sampled one is the rarest, never "1 in 0 moons".
|
||||
func TestRevealRareWellBeyondSample(t *testing.T) {
|
||||
s := veteranState()
|
||||
m := s.Moons[0]
|
||||
p := s.page(View{Section: "moons", Moon: m.ID}.resolve(s))
|
||||
if p.Moon == nil {
|
||||
t.Fatal("no moon on the page")
|
||||
}
|
||||
big := p.Moon.GradeConfig().WellSupplyMax * 100
|
||||
for i := range p.Moon.Wells {
|
||||
p.Moon.Wells[i].Supply = big
|
||||
}
|
||||
found := false
|
||||
for _, tr := range p.moonReveal().Traits {
|
||||
if strings.Contains(tr, "1 in 0 ") {
|
||||
t.Errorf("trait %q", tr)
|
||||
}
|
||||
if strings.HasPrefix(tr, "Rare: ") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no rare trait for wells bigger than any sampled")
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -146,7 +146,10 @@ func (s *server) apiRestore(w http.ResponseWriter, r *http.Request, code string)
|
||||
restoreRefused(w, "That save code doesn't match any miner")
|
||||
return ""
|
||||
}
|
||||
if isBanned(r.Context(), s.db, userID) {
|
||||
if banned, err := isBanned(r.Context(), s.db, userID); err != nil {
|
||||
apiServerError(w, err)
|
||||
return ""
|
||||
} else if banned {
|
||||
// (422, not 403: a client shows 403 as its own player suspended)
|
||||
restoreRefused(w, "That miner is suspended")
|
||||
return ""
|
||||
|
||||
+12
-3
@@ -90,7 +90,7 @@ func serve(ctx context.Context) error {
|
||||
if ms, _ := strconv.Atoi(os.Getenv("MM_API_DELAY_MS")); ms > 0 && s.dev {
|
||||
handler = delayGame(handler, time.Duration(ms)*time.Millisecond)
|
||||
}
|
||||
addr := ":" + env("PORT", "3000")
|
||||
addr := os.Getenv("HOST") + ":" + env("PORT", "3000") // HOST unset: every interface
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: logRequests(secureHeaders(limitBody(handler))),
|
||||
@@ -99,7 +99,11 @@ func serve(ctx context.Context) error {
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
// ListenAndServe returns as soon as Shutdown starts: wait for running handlers to
|
||||
// finish (done) before returning, which closes the pool.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
<-ctx.Done()
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -109,6 +113,7 @@ func serve(ctx context.Context) error {
|
||||
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
<-done
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -257,8 +262,9 @@ func clientIP(r *http.Request) string {
|
||||
}
|
||||
|
||||
// normalIP is an address (with or without a port) as one key per client: IPv4-mapped IPv6
|
||||
// as its IPv4, no zone, and every loopback address (127.0.0.0/8, ::1) as "loopback".
|
||||
// Anything unparsable is kept as it is.
|
||||
// as its IPv4, no zone, every loopback address (127.0.0.0/8, ::1) as "loopback", and an
|
||||
// IPv6 address as its /64 (one client holds a whole /64). Anything unparsable is kept as
|
||||
// it is.
|
||||
func normalIP(s string) string {
|
||||
if host, _, err := net.SplitHostPort(s); err == nil {
|
||||
s = host
|
||||
@@ -271,6 +277,9 @@ func normalIP(s string) string {
|
||||
if ip.IsLoopback() {
|
||||
return "loopback"
|
||||
}
|
||||
if ip.Is6() {
|
||||
return netip.PrefixFrom(ip, 64).Masked().String()
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// The new-player limit keys IPv6 clients by their /64, IPv4 by the address.
|
||||
func TestNormalIP(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"203.0.113.7:4000": "203.0.113.7",
|
||||
"[::ffff:203.0.113.7]:4000": "203.0.113.7",
|
||||
"[2001:db8:1:2:aaaa::1]:4000": "2001:db8:1:2::/64",
|
||||
"2001:db8:1:2:bbbb:cccc::9": "2001:db8:1:2::/64",
|
||||
"[fe80::1%eth0]:80": "fe80::/64",
|
||||
"[::1]:80": "loopback",
|
||||
"127.0.0.2": "loopback",
|
||||
"not an address": "not an address",
|
||||
} {
|
||||
if got := normalIP(in); got != want {
|
||||
t.Errorf("normalIP(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the database can't say whether the player exists, apiUser answers 500 and makes no
|
||||
// new player (the client would save its id over the real one).
|
||||
func TestAPIUserDatabaseDown(t *testing.T) {
|
||||
pool, err := pgxpool.New(context.Background(), "postgres://nobody@127.0.0.1:1/none?connect_timeout=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
s := &server{db: pool, newPlayers: newRateLimiter(10)}
|
||||
r := httptest.NewRequest("GET", "/api/state", nil)
|
||||
r.Header.Set(userHeader, testID("u", 1))
|
||||
w := httptest.NewRecorder()
|
||||
if id, created := s.apiUser(w, r); id != "" || created {
|
||||
t.Fatalf("apiUser = %q, %v; want no player", id, created)
|
||||
}
|
||||
if w.Code != http.StatusInternalServerError || w.Header().Get(userHeader) != "" {
|
||||
t.Fatalf("status %d, %s %q; want 500 and no id", w.Code, userHeader, w.Header().Get(userHeader))
|
||||
}
|
||||
}
|
||||
+11
-3
@@ -486,6 +486,7 @@ func simulate(c *GameConfig) *SimResult {
|
||||
var wells []*simMoon // unreserved supply by moon
|
||||
left := 0.0 // total unreserved supply
|
||||
moons := 0
|
||||
top := 0 // the dearest grade bought
|
||||
for _, id := range c.StartingTools {
|
||||
if i, ok := itemIndex[id]; ok {
|
||||
tools = append(tools, simTool{item: i})
|
||||
@@ -582,6 +583,7 @@ func simulate(c *GameConfig) *SimResult {
|
||||
break
|
||||
}
|
||||
credits -= moonPrice
|
||||
top = max(top, c.gradeIndex(g.Grade))
|
||||
// Wells in units at the average price, which is what the sim sells at.
|
||||
kind := c.Moons[rng.IntN(len(c.Moons))]
|
||||
var moon []float64
|
||||
@@ -605,7 +607,11 @@ func simulate(c *GameConfig) *SimResult {
|
||||
// either track for one owned tool of each kind; or an advisor.
|
||||
now := income(advisors)
|
||||
breakEven := func(cost, gain float64) Hours {
|
||||
return hours(max(0, cost-credits), now) + hours(cost, gain)
|
||||
wait := Hours(0) // what the player can pay now needs no income
|
||||
if cost > credits {
|
||||
wait = hours(cost-credits, now)
|
||||
}
|
||||
return wait + hours(cost, gain)
|
||||
}
|
||||
type choice struct {
|
||||
kind string // tool, upgrade, advisor
|
||||
@@ -661,8 +667,10 @@ func simulate(c *GameConfig) *SimResult {
|
||||
}
|
||||
consider(ch, income(append(slices.Clone(advisors), a.Name))-now)
|
||||
}
|
||||
// Keep enough for the next moon: with dry wells and no credits, nothing earns.
|
||||
if best.kind == "" || credits-best.cost < nextGrade(credits).Price {
|
||||
// Keep enough for the next moon: with dry wells and no credits, nothing earns. The
|
||||
// reserve is the dearest grade bought, not one picked from credits, which every
|
||||
// purchase would lower.
|
||||
if best.kind == "" || credits-best.cost < c.MoonGrades[top].Price {
|
||||
break
|
||||
}
|
||||
credits -= best.cost
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// With no starting tools there is no income, but the player can still buy a drill.
|
||||
func TestSimBuysWithoutIncome(t *testing.T) {
|
||||
c := baseConfig()
|
||||
c.StartingTools = nil
|
||||
c.StartingCredits = 2000 // a D moon, a PD-1 and the reserve for the next moon
|
||||
s := simulate(&c)
|
||||
n := 0
|
||||
for _, tl := range s.Tools {
|
||||
n += tl.Count
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatalf("the sim player bought no drill: %v", s.Events)
|
||||
}
|
||||
}
|
||||
|
||||
// The sims read the first store item: a config needs one.
|
||||
func TestValidateNeedsStoreItems(t *testing.T) {
|
||||
c := baseConfig()
|
||||
c.StoreItems, c.StartingTools = nil, nil
|
||||
if err := c.validate(); err == nil {
|
||||
t.Fatal("a config with no store items passed")
|
||||
}
|
||||
}
|
||||
|
||||
// The moon reserve doesn't shrink as the player spends: with credits for two B moons the
|
||||
// player buys a B moon and keeps its price, rather than spending down to a cheaper moon's
|
||||
// at once.
|
||||
func TestSimReserveHolds(t *testing.T) {
|
||||
c := baseConfig()
|
||||
c.StartingCredits = 2 * c.MoonGrades[c.gradeIndex("B")].Price
|
||||
s := simulate(&c)
|
||||
for _, e := range s.Events[1:] {
|
||||
if strings.HasPrefix(e, SimTime(0).String()) {
|
||||
t.Fatalf("bought at once after the first moon: %v", s.Events[:5])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The clicker sim player keeps the dearest moon grade it reached as its reserve, so it
|
||||
// doesn't spend down and value its drills on a cheaper moon later.
|
||||
func TestGradeSimGradeHolds(t *testing.T) {
|
||||
c := baseConfig()
|
||||
stats := gradeStats(&c)
|
||||
for _, pl := range gsPlayers {
|
||||
r := simulatePlayer(&c, pl, stats)
|
||||
top := 0
|
||||
for _, row := range r.Rows {
|
||||
g := c.gradeIndex(row.Grade)
|
||||
if g < top {
|
||||
t.Fatalf("%s player: grade %s at %s, after %s", pl.Name, row.Grade, row.At, c.MoonGrades[top].Grade)
|
||||
}
|
||||
top = g
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wells are stored by resource name, so the config can't price a resource the database
|
||||
// doesn't have.
|
||||
func TestValidateResourceNames(t *testing.T) {
|
||||
c := baseConfig()
|
||||
c.ResourcePrices = maps.Clone(c.ResourcePrices)
|
||||
c.ResourcePrices["Lunarite"] = 30
|
||||
if err := c.validate(); err == nil {
|
||||
t.Fatal("a config with an unknown resource passed")
|
||||
}
|
||||
}
|
||||
+15
-9
@@ -845,15 +845,21 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
|
||||
var all []Suggestion
|
||||
for i := range s.Moons {
|
||||
moon := &s.Moons[i]
|
||||
remain := s.project(moon).Remain
|
||||
for _, w := range moon.Wells {
|
||||
if w.Supply <= 0 {
|
||||
continue
|
||||
}
|
||||
// What a cycle deployed here would take from each well (several for a reach).
|
||||
// What a cycle deployed here would take from each well (several for a reach),
|
||||
// after the drills already on the moon: none and stakeInTx refuses it.
|
||||
_, yield := s.drill(*t, item, w.Resource)
|
||||
credits := 0.0
|
||||
for j, take := range moon.split(Stake{WellID: w.ID, Amount: yield, Reach: m.Reach}, moon.supply()) {
|
||||
credits, got := 0.0, 0.0
|
||||
for j, take := range moon.split(Stake{WellID: w.ID, Amount: yield, Reach: m.Reach}, remain) {
|
||||
credits += take * s.sellPrice(moon.Wells[j].Resource)
|
||||
got += take
|
||||
}
|
||||
if got <= 1e-9 {
|
||||
continue
|
||||
}
|
||||
row := s.wellRow(moon, w, "")
|
||||
all = append(all, Suggestion{MoonID: moon.ID, MoonName: moon.Name, Well: w,
|
||||
@@ -874,13 +880,13 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
|
||||
}
|
||||
return a.Supply > b.Supply
|
||||
})
|
||||
var bestRate, bestSupply float64
|
||||
for _, sg := range all {
|
||||
bestRate, bestSupply = max(bestRate, sg.PerMin), max(bestSupply, sg.Supply)
|
||||
}
|
||||
// Only call a well "highest value" when the values actually differ.
|
||||
distinct := all[0].PerMin != all[len(all)-1].PerMin
|
||||
// The labels compare the wells shown; "highest value" only when their values differ.
|
||||
all = all[:min(3, len(all))]
|
||||
bestRate, bestSupply, lowRate := all[0].PerMin, all[0].Supply, all[0].PerMin
|
||||
for _, sg := range all {
|
||||
bestRate, bestSupply, lowRate = max(bestRate, sg.PerMin), max(bestSupply, sg.Supply), min(lowRate, sg.PerMin)
|
||||
}
|
||||
distinct := bestRate != lowRate
|
||||
for i := range all {
|
||||
sg := &all[i]
|
||||
switch {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type buyWell struct{ supply, busy float64 }
|
||||
|
||||
// buyTestState is a new player with one moon of single-resource wells (supplies in units of
|
||||
// the new tool's yield; busy: what a drill already on the well takes, 0 for none) and a new
|
||||
// single-well tool.
|
||||
func buyTestState(t *testing.T, wells []buyWell) (*State, string, float64) {
|
||||
s := newPlayerState()
|
||||
var item *StoreItem
|
||||
for i := range s.Config.StoreItems {
|
||||
if it := &s.Config.StoreItems[i]; it.ToolReach() == 1 {
|
||||
item = it
|
||||
break
|
||||
}
|
||||
}
|
||||
if item == nil {
|
||||
t.Skip("no single-well tool in the config")
|
||||
}
|
||||
tool := Tool{ID: testID("t", 500), StoreItemID: item.ID}
|
||||
s.Tools = []Tool{tool}
|
||||
_, yield := s.drill(tool, item, "")
|
||||
res := s.Config.Resources[0]
|
||||
moon := Moon{ID: testID("m", 1), Name: "One", CreatedAt: s.Now.Add(-time.Hour)}
|
||||
for i, w := range wells {
|
||||
well := Well{ID: testID("w", i), Resource: res, Supply: w.supply * yield}
|
||||
moon.Wells = append(moon.Wells, well)
|
||||
if w.busy > 0 {
|
||||
other := Tool{ID: testID("t", 600+i), StoreItemID: item.ID}
|
||||
s.Tools = append(s.Tools, other)
|
||||
s.Stakes = append(s.Stakes, Stake{ID: testID("s", i), MoonID: moon.ID, WellID: well.ID, ToolID: other.ID,
|
||||
Resource: res, Amount: w.busy * yield, Start: s.Now, DurationInSecs: 60, Reach: 1})
|
||||
}
|
||||
}
|
||||
s.Moons = []Moon{moon}
|
||||
return s, tool.ID, yield
|
||||
}
|
||||
|
||||
// A well the drills already on it will empty is not suggested (stake would refuse it).
|
||||
func TestBuySuggestionsSkipCommittedWells(t *testing.T) {
|
||||
s, id, _ := buyTestState(t, []buyWell{{1, 1}})
|
||||
m := s.buySuggestions(id)
|
||||
if len(m.Suggestions) != 0 || !m.AllEmpty {
|
||||
t.Fatalf("suggestions %+v (allEmpty %v), want none: the well is fully committed", m.Suggestions, m.AllEmpty)
|
||||
}
|
||||
// With room for half a cycle, it is suggested, worth half a cycle.
|
||||
s, id, _ = buyTestState(t, []buyWell{{1.5, 1}})
|
||||
m = s.buySuggestions(id)
|
||||
if len(m.Suggestions) != 1 {
|
||||
t.Fatalf("suggestions %+v, want the well", m.Suggestions)
|
||||
}
|
||||
full := s.sellPrice(s.Moons[0].Wells[0].Resource) * s.Moons[0].Wells[0].Supply / 1.5
|
||||
if got := m.Suggestions[0].Credits; got < full/2-1e-6 || got > full/2+1e-6 {
|
||||
t.Fatalf("credits %v, want %v (half a cycle left)", got, full/2)
|
||||
}
|
||||
}
|
||||
|
||||
// "Highest value" goes to the best well shown, even when it is not first or last.
|
||||
func TestBuySuggestionsHighestValue(t *testing.T) {
|
||||
// A: idle, 3/4 of a cycle left; B: busy, plenty; C: busy, 3/4 of a cycle in the well,
|
||||
// half after its drill.
|
||||
s, id, _ := buyTestState(t, []buyWell{{0.75, 0}, {10, 1}, {0.75, 0.25}})
|
||||
m := s.buySuggestions(id)
|
||||
if len(m.Suggestions) != 3 {
|
||||
t.Fatalf("suggestions %+v, want 3", m.Suggestions)
|
||||
}
|
||||
if m.Suggestions[1].ID != s.Moons[0].Wells[1].ID || m.Suggestions[1].Reason != "Highest value" {
|
||||
t.Fatalf("second suggestion %+v, want well B with Highest value", m.Suggestions[1])
|
||||
}
|
||||
}
|
||||
@@ -900,12 +900,17 @@ function init() {
|
||||
}
|
||||
});
|
||||
|
||||
// Replies can come back out of order: only the newest request's reply is shown.
|
||||
let analyzeSeq = 0;
|
||||
async function analyze() {
|
||||
const pane = $("#tab-analysis");
|
||||
const seq = ++analyzeSeq;
|
||||
try {
|
||||
const raw = JSON.stringify(draft);
|
||||
const res = await post("/utils/economy/analyze", { raw });
|
||||
pane.innerHTML = await res.text();
|
||||
const html = await res.text();
|
||||
if (seq !== analyzeSeq) return;
|
||||
pane.innerHTML = html;
|
||||
const simBox = pane.querySelector("[data-sim]");
|
||||
if (simBox && simRun.raw === raw) simBox.innerHTML = simRun.html;
|
||||
else if (simBox && new URLSearchParams(location.search).has("sim")) simBox.querySelector("[data-sim-run]")?.click();
|
||||
@@ -923,6 +928,7 @@ function init() {
|
||||
pending.ref = ref;
|
||||
}
|
||||
} catch (err) {
|
||||
if (seq !== analyzeSeq) return;
|
||||
pane.innerHTML = `<p class="eco-bad">Analysis failed: ${esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -956,7 +962,11 @@ function init() {
|
||||
btn.setAttribute("aria-busy", "true");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const body = await postJSON("/utils/economy/save", { raw: JSON.stringify(draft), note: $("#eco-note").value });
|
||||
const body = await postJSON("/utils/economy/save", {
|
||||
raw: JSON.stringify(draft),
|
||||
note: $("#eco-note").value,
|
||||
since: data.latest ?? 0, // the server refuses the save if another was made since
|
||||
});
|
||||
if (body.errors) {
|
||||
mapErrors(body.errors);
|
||||
status(body.errors.join(" · "), true);
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
<figure class="lo-chart frame">
|
||||
<figcaption class="label">Credits per day · max {{cur .MaxCredits}}
|
||||
<span class="lo-legend"><i class="lo-key lo-earned"></i>Earned (sells) <i class="lo-key lo-spent"></i>Spent (moons, tools, upgrades)</span>
|
||||
<span class="lo-legend"><i class="lo-key lo-earned"></i>Earned (sales) <i class="lo-key lo-spent"></i>Spent (moons, tools, upgrades, refinery, advisors)</span>
|
||||
</figcaption>
|
||||
<div class="lo-bars">
|
||||
{{range .Days}}
|
||||
|
||||
Reference in new issue
Block a user