Compare commits

...
54 Commits
Author SHA1 Message Date
JosephFeranoandClaude Opus 5.5 329b954e31 Merge fix-native-build: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 b837a90f49 Merge fix-cl-ui: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 d4cf789906 Merge fix-cl-screens: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 4a8f6a5013 Merge fix-cl-moons: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 f5c796631f Merge fix-cl-core: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 623766b535 Merge fix-srv-sim: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 69ac96522c Merge fix-srv-admin: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 8ba5c6c9e2 Merge fix-srv-http: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 75faa787fd Merge fix-srv-econ: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 1e98c01cf3 Autoplay: press the reveal's Buy another (reveal-buy) instead of the hidden + New moon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 0c4c955ddf Autoplay: confirm buttons register their action and their confirm, so the refinery script reaches new-game
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 ebe72d9ee5 Moon lab: a save that finishes after leaving the lab still updates the game's moons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 e31a597f82 Market chart: measure the crosshair's hit box on every move, so scrolling doesn't leave it stale
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 330da8bb17 Config: refuse resource prices for resources the database doesn't have; fail a moon buy whose well isn't stored
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:58:55 +07:00
JosephFeranoandClaude Opus 5.5 42e11b1d7e Clicker sim: reserve the dearest moon grade bought, not one picked from credits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:57:56 +07:00
JosephFeranoandClaude Opus 5.5 4c2426370d Clicker sim: value purchases on the dearest moon grade bought, not the last
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:57:56 +07:00
JosephFeranoandClaude Opus 5.5 3595ff48e9 Economy sim: keep the dearest moon grade bought as the reserve, not one picked from credits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:56:49 +07:00
JosephFeranoandClaude Opus 5.5 fc2eea4681 fit_text: cut whole UTF-8 characters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 caa632fc18 Shake: a new shake on a shaking node keeps its resting place
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 e48b3fb512 Flex labels: copy the caller's opts so a shared table cuts every label
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 0135e66636 Focus: a remade list row drops its dead entry and keeps the plain key
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 05579a8fef Client: the redrill prediction picks drills by their amount with the yield event
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:46 +07:00
JosephFeranoandClaude Opus 5.5 8ea2989441 Client: the redeploy prediction finds the drill's own group (track levels too)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:46 +07:00
JosephFeranoandClaude Opus 5.5 803788cb72 Client: the well panel shows a multi-well drill's take from this well as here
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:32 +07:00
JosephFeranoandClaude Opus 5.5 1b6410ebfd Client: compact picks its unit after rounding (1M, not 1000k)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:32 +07:00
JosephFeranoandClaude Opus 5.5 22b592a533 Announcements: count the 200-character limit in characters, as the form does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:03 +07:00
JosephFeranoandClaude Opus 5.5 d3ff69a5eb Live ops dashboard: leave players whose return day is still running out of D1 and D7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:03 +07:00
JosephFeranoandClaude Opus 5.5 cbce3fab46 Live ops dashboard: count batch upgrades as spent and moon and tool sales as earned
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:03 +07:00
JosephFeranoandClaude Opus 5.5 e3af1799bf Economy analysis: name saved tier and grade points from the saved config, no panic with no drills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 cfa617ed93 Players page: send the whole page on an htmx history restore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 dfec5ec57a Economy editor: show only the newest analysis when replies come back out of order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 6d8f55c21a Economy editor: refuse a save from a page loaded before a newer save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 e34f06e1bc Config: refuse a game config with no store items
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:21 +07:00
JosephFeranoandClaude Opus 5.5 3aa4017525 Economy sim: buy what the player can pay now even with no income
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:09 +07:00
JosephFeranoandClaude Opus 5.5 549de045b0 Server: HOST picks the listen address; wt servers (MM_DEV=1, no admin login) listen on 127.0.0.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:50:25 +07:00
JosephFeranoandClaude Opus 5.5 f3be4066e8 wt: refuse task names over 60 characters (mm_<task> must fit Postgres's 63-byte names)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:50:25 +07:00
JosephFeranoandClaude Opus 5.5 a66e32acef dev.py: a pid counts as the dev game or its Xvfb only if its command line says so
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:27 +07:00
JosephFeranoandClaude Opus 5.5 bd36b8fc4f Buy modal: suggest only wells with something left after current drills, and label the highest value among those shown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 228e7af4c9 API: answer 500 when the player or ban lookup fails, instead of making a new player or skipping the ban
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 8263491301 New-player limit: count an IPv6 client by its /64
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 c7ff9b27c7 Server: wait for running requests to finish before closing the database on shutdown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 1bc3677f50 drive.py --keep: leave the Xvfb display the kept game draws on running
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:08 +07:00
JosephFeranoandClaude Opus 5.5 2a0eb04b12 buy-moon checks the new-moon gate again under the player's row lock, so two buys at once can't both pass it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:03 +07:00
JosephFeranoandClaude Opus 5.5 9ec5436fb8 drive.py and views.sh: no default server, MM_API or --api is required
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:00 +07:00
JosephFeranoandClaude Opus 5.5 61b696b08a Moon viewport: round its size once so it doesn't flip by a pixel while it moves
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:48:36 +07:00
JosephFeranoandClaude Opus 5.5 7556795a65 Local Postgres: publish its port on 127.0.0.1 only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:48:36 +07:00
JosephFeranoandClaude Opus 5.5 9eb20f9e15 Client test hook: a failing multi-frame step replies with its error instead of failing every frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:48:05 +07:00
JosephFeranoandClaude Opus 5.5 1258b774c3 Client: each recipe's Refine and the market's Sell get their own busy key and test id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:51 +07:00
JosephFeranoandClaude Opus 5.5 430b560347 Redrill on a full well tries every idle drill, not only those before the first that can't go
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:48 +07:00
JosephFeranoandClaude Opus 5.5 738cb32eb1 Stake and redrill lock the moon's wells before the tools, as claims do, so they can't deadlock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:38 +07:00
JosephFeranoandClaude Opus 5.5 74ce60805a Reveal card: a well bigger than every sampled one reads 'under 1 in N moons', not '1 in 0'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:28 +07:00
JosephFeranoandClaude Opus 5.5 bc95fce37a Client: view history moves only when the back/forward answer comes, and not on a failed one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:26 +07:00
JosephFeranoandClaude Opus 5.5 bd363b3bde Count a live event on all resources once in the cycle and refinery speed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:46:06 +07:00
JosephFeranoandClaude Opus 5.5 fac922ddd0 Count a speed event once in PriceBoosts.For("all")
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:45:09 +07:00
51 changed files with 767 additions and 170 deletions

No files matched your search

+25 -5
View File
@@ -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.
+14 -4
View File
@@ -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 -2
View File
@@ -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
+6 -3
View File
@@ -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)
+18 -5
View File
@@ -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
View File
@@ -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()
+2 -2
View File
@@ -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
+19 -1
View File
@@ -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
+2 -2
View File
@@ -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,
+3 -7
View File
@@ -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,
+28 -2
View File
@@ -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
+2 -2
View File
@@ -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
+3 -1
View File
@@ -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
+21 -4
View File
@@ -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
+1 -1
View File
@@ -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)
+7 -3
View File
@@ -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)
+1 -1
View File
@@ -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 } })
+3
View File
@@ -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)
+5 -2
View File
@@ -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
View File
@@ -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"])
+1 -1
View File
@@ -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"),
]
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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").
+16
View File
@@ -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)
}
}
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))
}
+28
View File
@@ -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
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
+48
View File
@@ -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
View File
@@ -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
+74
View File
@@ -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
View File
@@ -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 {
+75
View File
@@ -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])
}
}
+12 -2
View File
@@ -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);
+1 -1
View File
@@ -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}}