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 }. -- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
local running = {} local running = {}
local shaking = {} -- { node, t, rest = { p } } of the shakes running (M.shake)
local function now() local function now()
return socket.gettime() return socket.gettime()
@@ -208,6 +209,7 @@ end
-- Drop every frame-driven tween (a screen going away). -- Drop every frame-driven tween (a screen going away).
function M.clear() function M.clear()
running = {} running = {}
shaking = {}
end end
---------------------------------------------------------------- fading content ---------------------------------------------------------------- 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/ -- 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. -- 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 } } 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) function M.shake(node, amp, dur, delay)
if M.reduced then if M.reduced then
return return
end end
amp, dur = amp or 6, dur or 0.4 amp, dur = amp or 6, dur or 0.4
local p local rest = {}
return M.tween(node, dur, function(k, n) local keep = {}
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay) 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)) gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
end, delay or 0, function() end, delay or 0, function()
if p then if rest.p then
pcall(gui.set_position, node, p) pcall(gui.set_position, node, rest.p)
end end
end) end)
shaking[#shaking + 1] = { node = node, t = t, rest = rest }
return t
end end
-- 1D value noise (smooth), for shakes. -- 1D value noise (smooth), for shakes.
+14 -4
View File
@@ -28,6 +28,16 @@ local M = {}
local EPS = 0.01 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) local function differs(a, b)
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
end 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) 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) local n, w = ui.rich(parent, str, style, 0, 0)
gui.set_pivot(n, gui.PIVOT_W) gui.set_pivot(n, gui.PIVOT_W)
local s = opts or {} local s = copy(opts)
s.fit, s.height = nil, h s.fit, s.height = nil, h
return { node = n, size = false, style = s, measure = function() return w, h end } return { node = n, size = false, style = s, measure = function() return w, h end }
end end
@@ -124,7 +134,7 @@ function M.label(parent, str, style, h, opts)
w = w - ui.text_width(n) w = w - ui.text_width(n)
gui.set_text(n, t) gui.set_text(n, t)
end end
local s = opts or {} local s = copy(opts)
local fit, shrink = s.fit or s.shrink_text, s.shrink_text local fit, shrink = s.fit or s.shrink_text, s.shrink_text
s.fit, s.shrink_text, s.height = nil, nil, h 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 } 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 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 local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
gui.set_leading(n, lh / line) gui.set_leading(n, lh / line)
local s = opts or {} local s = copy(opts)
local lines = s.lines local lines = s.lines
s.lines = nil s.lines = nil
local measure = M.text_measure(n, lh) 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 -- 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). -- the box's middle, h tall, as wide as its text (set the text first).
function M.node_label(node, h, opts) 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 local fit, shrink = s.fit or s.shrink_text, s.shrink_text
s.fit, s.shrink_text, s.height = nil, nil, h 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 } 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 entries = {} -- { key, node, kind, label, zone, scope }
local scope = "page" 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) local function alive(node)
return pcall(gui.get_id, node) return pcall(gui.get_id, node)
@@ -67,8 +67,30 @@ function M.add(id, node, kind, label, zone)
return return
end end
local base = scope .. ":" .. (id or ("label:" .. tostring(label))) 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 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 } entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
end 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)) gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
local br = gui.get_screen_position(probe) local br = gui.get_screen_position(probe)
gui.delete_node(probe) gui.delete_node(probe)
local x0, x1 = math.floor(tl.x + 0.5), math.floor(br.x + 0.5) -- The size is rounded once (not as the difference of two rounded corners), so it stays
local y0, y1 = math.floor(br.y + 0.5), math.floor(tl.y + 0.5) -- the same while the node moves by fractions of a pixel: every new size remakes the
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x) -- 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 end
local function set_node(node, quiet) 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 for _, s in ipairs(list) do
local tool = M.tool(st, s.toolId) local tool = M.tool(st, s.toolId)
local w = wells(st, s.wellId)[1] 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)) 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). -- Not on a drained or fully committed well (the server leaves the tool idle).
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, 1) or 1) > EPS then if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, 1) or 1) > EPS then
@@ -713,16 +720,22 @@ M.register("redrill", function(st, p, ctx)
if left <= EPS then if left <= EPS then
break break
end 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 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 pick = i
end end
end end
if not pick then if not pick then
pick = 1 pick = 1
for i, t in ipairs(tools) do for i, a in ipairs(amounts) do
if t.yield > tools[pick].yield then if a > amounts[pick] then
pick = i pick = i
end end
end end
+13 -8
View File
@@ -198,10 +198,11 @@ local function visible(node)
return true return true
end end
-- A run of commands spread over frames: { frame_fn, ... }. -- A run of commands spread over frames: { frame_fn, ... }. id: the command's, for the
local function steps(g, list, done) -- 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 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 end
-- mm.test_dir (drive.py passes its output folder): replies go to files there. -- 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() steps(g, list, function()
reply(cmd.id, { ok = true, id = e.id, x = x, y = y }) reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
end) end, cmd.id)
end end
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds. -- 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() input(k, { released = true }) end,
}, function() }, function()
reply(cmd.id, { ok = true }) reply(cmd.id, { ok = true })
end) end, cmd.id)
end end
function COMMANDS.type(g, cmd, input) function COMMANDS.type(g, cmd, input)
@@ -605,7 +606,7 @@ function COMMANDS.dump(g, cmd)
steps(g, { function() end, function() steps(g, { function() end, function()
local ok, d = pcall(dump, g) local ok, d = pcall(dump, g)
reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) }) reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) })
end }) end }, nil, cmd.id)
end end
function COMMANDS.state(g) function COMMANDS.state(g)
@@ -692,9 +693,13 @@ function M.update(g, input, api)
-- Running multi-frame steps. -- Running multi-frame steps.
if g.test_steps and #g.test_steps > 0 then if g.test_steps and #g.test_steps > 0 then
local s = g.test_steps[1] local s = g.test_steps[1]
s.list[s.i]() local ok, err = pcall(s.list[s.i])
s.i = s.i + 1 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) table.remove(g.test_steps, 1)
if s.done then if s.done then
s.done() s.done()
+2 -2
View File
@@ -274,8 +274,8 @@ function M.fit_text(node, max_w)
return return
end end
while #text > 1 do while #text > 1 do
-- (a cut entity like "&am" would show as text) -- (a whole UTF-8 character at a time; a cut entity like "&am" would show as text)
text = text:sub(1, -2):gsub("&[%a#]*$", ""):gsub("%s+$", "") text = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", ""):gsub("&[%a#]*$", ""):gsub("%s+$", "")
gui.set_text(node, text .. "…") gui.set_text(node, text .. "…")
if M.text_width(node) <= max_w then if M.text_width(node) <= max_w then
return return
+19 -1
View File
@@ -12,6 +12,7 @@ local moon_view = require("modules.moon.moon")
local reveal = require("screens.game.reveal") local reveal = require("screens.game.reveal")
local moons = require("screens.game.moons") local moons = require("screens.game.moons")
local moon_sale = require("screens.game.moon_sale") local moon_sale = require("screens.game.moon_sale")
local predict = require("modules.predict")
local panel = require("components.panel") local panel = require("components.panel")
local scroll = require("components.scroll") local scroll = require("components.scroll")
local bar = require("components.bar") 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 = moon.wide
local wide_here = {} local wide_here = {}
if wide and #(wrow.wide or {}) > 0 then 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 for _, d in ipairs(wide.drills or {}) do
if contains(wrow.wide, d.id) then 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 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.g.view.section == "new-moon" and shot("11-new-moon") end,
function(a) return a:press("buy-moon") end, function(a) return a:press("buy-moon") end,
function(a) return a.g.reveal:card_done() and shot("12-reveal") 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). -- Buy another, straight from the card (its button registers as reveal-buy).
function(a) return a:press("buy-moon") end, 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.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 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, 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 a.g.view.section == "market" and shot("10-market-leaderboard") end,
function(a) return go(a, "help") end, function(a) return go(a, "help") end,
function(a) return a.g.view.section == "help" and shot("11-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. -- Start over: open its confirm, then run new-game (as its OK).
function(a) function(a) return a:press("confirm:new-game") end,
a.g.help_armed = a.g.h.now(a.g) + 30000 function(a) return a.g.dialog.root and shot("12-help-confirm") end,
a.g.dirty = true
return true
end,
function() return shot("12-help-confirm") end,
function(a) return a:press("new-game") end, function(a) return a:press("new-game") end,
function(a) return a.g.state.view.section ~= "help" or true end, function(a) return a.g.state.view.section ~= "help" or true end,
function() return shot("13-new-game") 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 chip = require("components.chip")
local stat = require("components.stat") local stat = require("components.stat")
local fg = require("modules.flex_gui") local fg = require("modules.flex_gui")
local blocks = require("modules.blocks")
local S = theme.size local S = theme.size
@@ -31,11 +32,12 @@ function M.compact(n)
local function one(f) local function one(f)
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", "")) return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
end 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" return one(n / 1e6) .. "M"
elseif a >= 1e4 then elseif a >= 1e4 then
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k" 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" return one(n / 1e3) .. "k"
end end
return ui.money(math.floor(n + 0.5)) 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
end }) end })
self.fx:button(id, b) 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 end
---------------------------------------------------------------- inline confirm ---------------------------------------------------------------- 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_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none") local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
gui.set_enabled(cross, false) gui.set_enabled(cross, false)
local rect
local shown = -1 local shown = -1
local function move(action) 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 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 and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
if not inside then if not inside then
+3 -1
View File
@@ -113,9 +113,11 @@ end
-- spinner and ignores clicks until the server answers. -- 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 -- 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). -- 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). -- 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) 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 if g.inflight[key] then
button.set_busy(b) button.set_busy(b)
end end
+21 -4
View File
@@ -128,6 +128,9 @@ local function suspend(g)
g.refetch = nil g.refetch = nil
end end
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {} 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 } g.boot = { text = SUSPENDED }
if g.dialog and g.dialog:is_open() then if g.dialog and g.dialog:is_open() then
g.dialog:close("cancel") g.dialog:close("cancel")
@@ -215,7 +218,9 @@ end
-- The views shown, like the web's browser history (main.js syncURL: every response whose -- 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 -- 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 VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
local function view_key(v) local function view_key(v)
@@ -242,7 +247,14 @@ local function remember(g, from_history)
if from_history then if from_history then
if h.list[from_history] then if h.list[from_history] then
h.list[from_history] = v h.list[from_history] = v
h.i = from_history
end end
if h.want == from_history then
h.want = nil
end
return
end
if h.want then
return return
end end
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then 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 elseif g.base then
show(g) -- rolled back show(g) -- rolled back
end 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 if status == 403 then
suspend(g) suspend(g)
g.inflight, g.busy, g.nav_view = {}, false, nil 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. -- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
function M.go(g, dir) function M.go(g, dir)
local h = history(g) 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 if not v then
return false return false
end end
h.i = h.i + dir h.want = i
local to = { from_history = h.i } local to = { from_history = i }
for k, x in pairs(v) do for k, x in pairs(v) do
to[k] = x to[k] = x
end 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 if not empty then
sell_now = H.action(self, b1, n1, "sell", function() sell_now = H.action(self, b1, n1, "sell", function()
return { resource = q.item, amount = f.text, pick = q.item } 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
end, { width = half }), end, { width = half }),
fg.button(c, "Sell all", { disabled = empty }, function(b2, n2) 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) show_status(L)
local sent = clone(L.draft) local sent = clone(L.draft)
api.save_moon_settings(sent, function(err) 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 if g.lab ~= L then
return return
end end
L.saving = false L.saving = false
if err then if err then
L.error = "Save failed: " .. err L.error = "Save failed: " .. err
g.toasts:push("Moon settings not saved: " .. err, "error", true)
else else
L.saved = sent 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 end
show_status(L) show_status(L)
end) 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() refine = H.action(self, b, nodes, "refine", function()
local q = self.refine_qty and self.refine_qty[r.item] or "1" local q = self.refine_qty and self.refine_qty[r.item] or "1"
return { recipe = r.item, qty = (q ~= "" and q 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") tooltip.add(b, "Refine the number in the box")
-- Enter in the quantity refines (the web's form). -- Enter in the quantity refines (the web's form).
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } }) 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 r.card then
if g.autoplay then if g.autoplay then
g.autoplay:register("reveal-close", {}, function() self:close() end) 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 end
if r.another_btn and not r.another_ok then if r.another_btn and not r.another_ok then
tooltip.add(r.another_btn, r.another_why) tooltip.add(r.another_btn, r.another_why)
+5 -2
View File
@@ -63,11 +63,14 @@ def read(name, default=None):
def alive(pid): 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: try:
os.kill(int(pid), 0) with open("/proc/%d/cmdline" % int(pid), "rb") as f:
return True cmd = f.read()
except (OSError, ValueError, TypeError): except (OSError, ValueError, TypeError):
return False return False
return any(n in cmd for n in (b"dmengine", b"MoonMiners", b"Xvfb"))
def snapshot(): 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 scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
with run(game, args). with run(game, args).
Options: 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) --size WxH window and display size (default 1280x800)
--out DIR screenshots and game.log (default dist/drive/<scenario>) --out DIR screenshots and game.log (default dist/drive/<scenario>)
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default --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 --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 (`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY) from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
--log FILE the attached game's log (default dist/dev/game.log) --log FILE the attached game's log (default dist/dev/game.log)
--config K=V extra engine config (mm.player=..., repeatable) --config K=V extra engine config (mm.player=..., repeatable)
--fixture make a test player first (scripts/fixture.py against --api, which must --fixture make a test player first (scripts/fixture.py against --api) and play as
then be given, or MM_API) and play as it; the scenario gets its ids as game.fixture it; the scenario gets its ids as game.fixture
--fixture-file F use the ids saved in F (a fixture.py output) instead --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 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 = argparse.ArgumentParser(add_help=True)
ap.add_argument("scenario") ap.add_argument("scenario")
ap.add_argument("rest", nargs="*") 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("--size", default="1280x800")
ap.add_argument("--out") ap.add_argument("--out")
ap.add_argument("--bundle", action="store_true") ap.add_argument("--bundle", action="store_true")
@@ -392,6 +393,9 @@ def main():
ap.add_argument("--fixture", action="store_true") ap.add_argument("--fixture", action="store_true")
ap.add_argument("--fixture-file") ap.add_argument("--fixture-file")
args = ap.parse_args() 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: if args.attach:
disp = os.path.join(ROOT, "dist/dev/display") disp = os.path.join(ROOT, "dist/dev/display")
@@ -413,8 +417,12 @@ def main():
try: try:
code = subprocess.call([sys.executable] + sys.argv, env=env) code = subprocess.call([sys.executable] + sys.argv, env=env)
finally: finally:
xvfb.terminate() if args.keep:
xvfb.wait() # 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) sys.exit(code)
path = find_scenario(args.scenario) path = find_scenario(args.scenario)
@@ -428,9 +436,6 @@ def main():
if not args.attach: if not args.attach:
args.config.append("mm.player=" + fixture["player"]) args.config.append("mm.player=" + fixture["player"])
elif args.fixture: 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])) fixture = json.loads(subprocess.check_output([sys.executable, os.path.join(HERE, "fixture.py"), args.api]))
args.config.append("mm.player=" + fixture["player"]) args.config.append("mm.player=" + fixture["player"])
print("fixture player", fixture["player"]) print("fixture player", fixture["player"])
+1 -1
View File
@@ -34,7 +34,7 @@ def run(game, args):
clicks = [ clicks = [
("moons", "act:claim?claim-well"), ("moons", "act:claim?claim-well"),
("refinery", "act:collect?job"), ("refinery", "act:collect?job"),
("market", "act:sell"), ("market", "act:sell?pick"),
("store", "act:buy-tool?id"), ("store", "act:buy-tool?id"),
("equipment", "act:upgrade?tool"), ("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: # 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/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 # 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 set -euo pipefail
bin=$1; out=$2; fx=$3; size=${4:-1280x800} 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'])"; } 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) player=$(get player); moon=$(get moon); well=$(get well); drill=$(get drill_mining)
settle=4; [ "$size" != 1280x800 ] && settle=15 settle=4; [ "$size" != 1280x800 ] && settle=15
+1 -1
View File
@@ -9,7 +9,7 @@ services:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: moonminers POSTGRES_DB: moonminers
ports: ports:
- "5432:5432" - "127.0.0.1:5432:5432"
volumes: volumes:
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
+6 -3
View File
@@ -22,6 +22,8 @@ die() { echo "wt: $*" >&2; exit 1; }
task_vars() { task_vars() {
TASK=${1:-} TASK=${1:-}
[[ $TASK =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "task name: lowercase letters, digits and -" [[ $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 WT=$WS/wt-$TASK
DB=mm_${TASK//-/_} DB=mm_${TASK//-/_}
ST=$WT/.wt ST=$WT/.wt
@@ -63,7 +65,8 @@ start_server() {
{ tail -5 "$ST/migrate.log"; die "migrate failed (log $ST/migrate.log)"; } { tail -5 "$ST/migrate.log"; die "migrate failed (log $ST/migrate.log)"; }
rm -f "$ST/pid" rm -f "$ST/pid"
# Its own session (not killed with the caller's), pid written by the process itself. # 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' \ setsid -f bash -c 'echo $$ >"$PIDFILE"; exec bin/moonminers serve' \
>"$ST/server.log" 2>&1 </dev/null) >"$ST/server.log" 2>&1 </dev/null)
for _ in $(seq 25); do [[ -s $ST/pid ]] && break; sleep 0.1; done for _ in $(seq 25); do [[ -s $ST/pid ]] && break; sleep 0.1; done
@@ -126,7 +129,7 @@ up() {
start_server start_server
fi fi
echo "worktree $WT" 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)" echo "stop: $0 down $TASK (or rm $TASK to also remove the worktree)"
} }
@@ -179,7 +182,7 @@ case ${1:-} in
up) up "${2:-}" ;; up) up "${2:-}" ;;
down) down "${2:-}" ;; down) down "${2:-}" ;;
rm) remove "${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 ;; status) status ;;
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;; *) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac 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=`), - `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. the `Page` view model, formatting helpers, the admin templates' functions.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body - `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). action).
- `admin.go`: the admin login (`ADMIN_PASSWORD`, `/utils/login`, `requireAdmin` around every - `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`). 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_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 `admin-grant`, saving moon settings). With `MM_DEV=1` there is no admin login and every
player is an admin. player is an admin.
`PORT` (default 3000) and `HOST` (unset: every interface; `scripts/wt` sets `127.0.0.1`).
## Conventions ## Conventions
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin. - 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 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` 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`, (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 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 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) { if !s.newPlayers.allow(r) {
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute") apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
return "", false return "", false
@@ -652,7 +660,10 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
return "", false return "", false
} }
created = true 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") apiError(w, http.StatusForbidden, "This account is suspended")
return "", false 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, `{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId,
moonName, wellId, resource, supply, credits, perMin, idle, reason}]}`: up to 3 wells to 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 `reason` `best-value`, `most-supply`, `no-drill` or `shared`, `reasonText` its text
("Highest value"). ("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" "encoding/json"
"fmt" "fmt"
"log" "log"
"slices"
"strconv" "strconv"
"strings" "strings"
@@ -122,6 +123,10 @@ type MoonType struct {
var game = mustParseGameConfig(gameJSON) 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 { func mustParseGameConfig(data []byte) GameConfig {
var cfg GameConfig var cfg GameConfig
dec := json.NewDecoder(bytes.NewReader(data)) 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") return fmt.Errorf("equipmentResale.factor must be at least 0 and below 1")
} }
for name, price := range cfg.ResourcePrices { 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 { if price < 0 {
return fmt.Errorf("%s price is negative", name) return fmt.Errorf("%s price is negative", name)
} }
} }
if len(cfg.StoreItems) == 0 {
return fmt.Errorf("no store items")
}
ids := map[int]bool{} ids := map[int]bool{}
for _, item := range cfg.StoreItems { for _, item := range cfg.StoreItems {
if item.Price < 0 || item.ClaimAmount < 0 || item.CompletionTimeInSecs < 1 { 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 // saveOverride records a new override (by the given admin), re-syncs the store items and
// puts it in effect. // puts it in effect. With since >= 0 it refuses when the newest saved version is not since
func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note, userID string) (int64, error) { // (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) c, err := applyOverride(ov)
if err != nil { if err != nil {
return 0, gameErr("%s", err.Error()) return 0, gameErr("%s", err.Error())
@@ -308,6 +309,19 @@ func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note
} }
var id int64 var id int64
err = inTx(ctx, db, func(tx pgx.Tx) error { 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, err := tx.QueryRow(ctx,
`insert into config_override(data, note, created_by) `insert into config_override(data, note, created_by)
values ($1, $2, (select id from users where id::text = $3)) returning id`, 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) serverError(w, err)
return 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() base := baseConfig()
c := cfg() c := cfg()
quotes := marketQuotes(c, time.Now()) quotes := marketQuotes(c, time.Now())
@@ -509,6 +529,7 @@ func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
"saved": configDoc(c), "saved": configDoc(c),
"base": configDoc(&base), "base": configDoc(&base),
"version": overrideVersion.Load(), "version": overrideVersion.Load(),
"latest": latest,
"market": quotes, "market": quotes,
"colors": resourceColors, "colors": resourceColors,
} }
@@ -542,6 +563,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
} }
var ov ConfigOverride var ov ConfigOverride
var note string var note string
since := int64(-1) // save: the version the page loaded with
switch r.PathValue("action") { switch r.PathValue("action") {
case "analyze": case "analyze":
draft, err := decodeConfig([]byte(r.PostFormValue("raw"))) 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()}}) reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
return 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 { if diffConfig(cfg(), &draft).ChangeCount() == 0 {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"No changes to save"}}) reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"No changes to save"}})
return return
@@ -595,7 +621,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
id, err := saveOverride(ctx, s.db, ov, note, "") id, err := saveOverride(ctx, s.db, ov, note, "", since)
if err != nil { if err != nil {
if isGameError(err) { if isGameError(err) {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}}) 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 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 var exists bool
err := db.QueryRow(ctx, "select exists(select 1 from users where id = $1)", userID).Scan(&exists) 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) { 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 { if err != nil {
return err 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 owned int
var last string var last string
err = tx.QueryRow(ctx, ` 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) wells := rollWells(cfg(), g, kind, owned == 0, mrand.Float64)
rating := moonRating(g, wells) rating := moonRating(g, wells)
for _, w := range wells { for _, w := range wells {
_, err = tx.Exec(ctx, ` tag, err := tx.Exec(ctx, `
insert into resource_well(source_id, resource_id, supply) insert into resource_well(source_id, resource_id, supply)
select $1, id, $3 from resource where name = $2`, select $1, id, $3 from resource where name = $2`,
moonID, w.Resource, w.Supply) moonID, w.Resource, w.Supply)
if err != nil { if err != nil {
return err 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). // Describe the moon from its look and wells (moondesc.go).
desc, err := moonDescription(ctx, tx, moonID, g.MeanSupply()) 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. // 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 { func stake(ctx context.Context, db *pgxpool.Pool, userID, toolID, wellID string) error {
return inTx(ctx, db, func(tx pgx.Tx) 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) toolID, err := pickTool(ctx, tx, userID, toolID, true)
if err != nil { if err != nil {
return err return err
@@ -951,27 +966,32 @@ func moonInTx(ctx context.Context, tx pgx.Tx, userID, moonID string) (*Moon, []S
return m, stakes, err return m, stakes, err
} }
func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) error { // lockMoonWells locks every well of the player's moon that has wellID, in id order.
if !uuidPattern.MatchString(toolID) { func lockMoonWells(ctx context.Context, tx pgx.Tx, userID, wellID string) error {
return gameErr("Tool not found")
}
if !uuidPattern.MatchString(wellID) { if !uuidPattern.MatchString(wellID) {
return gameErr("Well not found") 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, ` _, err := tx.Exec(ctx, `
select 1 from resource_well where source_id = ( select 1 from resource_well where source_id = (
select resource_well.source_id from resource_well select resource_well.source_id from resource_well
join staking_source on staking_source.id = resource_well.source_id join staking_source on staking_source.id = resource_well.source_id
where resource_well.id = $1 and staking_source.user_id = $2) where resource_well.id = $1 and staking_source.user_id = $2)
order by id for update`, wellID, userID) 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 return err
} }
var storeItemID int var storeItemID int
var lv Levels var lv Levels
err = tx.QueryRow(ctx, ` err := tx.QueryRow(ctx, `
select store_item_id, yield_level, cycle_level from inventory_item select store_item_id, yield_level, cycle_level from inventory_item
where id = $1 and user_id = $2 where id = $1 and user_id = $2
for update`, toolID, userID).Scan(&storeItemID, &lv.Yield, &lv.Cycle) 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 var n int
err := inTx(ctx, db, func(tx pgx.Tx) error { 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, ` rows, err := tx.Query(ctx, `
select inventory_item.id, store_item_id, yield_level, cycle_level from inventory_item select inventory_item.id, store_item_id, yield_level, cycle_level from inventory_item
join lateral ( join lateral (
@@ -1290,16 +1314,23 @@ func redrill(ctx context.Context, db *pgxpool.Pool, userID, wellID string) (int,
} }
if left <= 1e-9 { if left <= 1e-9 {
// This well is full: drills that mine several wells may still have work on // 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 { for _, t := range tools {
err := stakeInTx(ctx, tx, userID, t.ID, wellID) err := stakeInTx(ctx, tx, userID, t.ID, wellID)
if (err == errWellCommitted || err == errWellEmpty) && n > 0 { if err == errWellCommitted || err == errWellEmpty {
break if full == nil {
full = err
}
continue
} else if err != nil { } else if err != nil {
return err return err
} }
n++ n++
} }
if n == 0 && full != nil {
return full
}
break break
} }
amounts := make([]float64, len(tools)) 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 var advisors []string
seats := 1 seats := 1
refinery := c.Refinery.UnlockGrade == "" 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{} pressure := map[string]float64{}
stock := map[string]float64{} stock := map[string]float64{}
var resources []string var resources []string
@@ -394,7 +394,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
freeOre += m.free freeOre += m.free
open = append(open, len(moons)) open = append(open, len(moons))
moons = append(moons, m) moons = append(moons, m)
grade = g grade = max(grade, g)
if _, ok := res.FirstGrade[gr.Grade]; !ok { if _, ok := res.FirstGrade[gr.Grade]; !ok {
res.FirstGrade[gr.Grade] = SimTime(t) res.FirstGrade[gr.Grade] = SimTime(t)
res.GradeClicks[gr.Grade] = clicks res.GradeClicks[gr.Grade] = clicks
@@ -583,10 +583,11 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
wantBuy = false wantBuy = false
for range 50 { for range 50 {
b := best("", pl.All) 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 reserve := 0.0
if freeOre < 20*need() { if freeOre < 20*need() {
reserve = c.MoonGrades[nextGrade(income)].Price reserve = c.MoonGrades[grade].Price
} }
if b.Kind == "" || credits-b.Cost < reserve { if b.Kind == "" || credits-b.Cost < reserve {
break break
+13 -10
View File
@@ -12,6 +12,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
@@ -27,9 +28,10 @@ func logEvent(ctx context.Context, q querier, userID, kind string, data map[stri
return err 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). // 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 // ------------------------------------------------------------------ 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') where created_at >= day and created_at < day + 1 and kind not in ('session', 'new_player')
and kind not like 'admin\_%'), and kind not like 'admin\_%'),
(select coalesce(sum((data->>'credits')::float8), 0) from event_log (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 (select coalesce(sum((data->>'price')::float8), 0) from event_log
where created_at >= day and created_at < day + 1 and kind in (`+spendKinds+`)) where created_at >= day and created_at < day + 1 and kind in (`+spendKinds+`))
from days order by day`) 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 d.ActiveToday = d.Days[n-1].Active
} }
// Dn retention: of the players who joined in the last 30 days at least n days ago, // Dn retention: of the players who joined in the last 30 days whose day n is over
// the share that did something n days after the day they joined. // (joined at least n+1 days ago), the share that did something n days after the day
// they joined.
err = q.QueryRow(ctx, ` err = q.QueryRow(ctx, `
with cohort as ( with cohort as (
select id, created_at::date as day from users select id, created_at::date as day from users
where created_at >= current_date - 30 where created_at >= current_date - 30
) )
select select
count(*) filter (where day <= current_date - 1), count(*) filter (where day <= current_date - 2),
count(*) filter (where day <= current_date - 1 and exists ( count(*) filter (where day <= current_date - 2 and exists (
select 1 from event_log where user_id = cohort.id 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\_%')), 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 - 8),
count(*) filter (where day <= current_date - 7 and exists ( count(*) filter (where day <= current_date - 8 and exists (
select 1 from event_log where user_id = cohort.id 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\_%')) 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) 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 { switch {
case a.Message == "": case a.Message == "":
msg = "Write 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" msg = "Keep the message under 200 characters"
case !slices.Contains(announcementLevels, a.Level): case !slices.Contains(announcementLevels, a.Level):
msg = "Pick 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". // PriceBoosts are the active price_multiplier events: by resource name, and "all".
type PriceBoosts map[string]float64 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 { func (b PriceBoosts) For(name string) float64 {
m := 1.0 m := 1.0
if v, ok := b["all"]; ok { if v, ok := b["all"]; ok {
m *= v m *= v
} }
if v, ok := b[name]; ok { if v, ok := b[name]; ok && name != "all" {
m *= v m *= v
} }
return m 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 == "" { if feature == "" {
return nil return nil
} }
if feature == "new-moon" {
return newMoonAllowed(ctx, db, userID)
}
s := &State{} s := &State{}
err := db.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&s.Onboarding) err := db.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&s.Onboarding)
if err != nil { if err != nil {
return err 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) { if !s.Unlocked(feature) {
return gameErr("Not available yet") return gameErr("Not available yet")
} }
return nil 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. // adminOnboarding resets a player's onboarding (reset) or unlocks everything.
func adminOnboarding(ctx context.Context, db *pgxpool.Pool, adminID, userID string, reset bool) error { func adminOnboarding(ctx context.Context, db *pgxpool.Pool, adminID, userID string, reset bool) error {
return inTx(ctx, db, func(tx pgx.Tx) 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 // ------------------------------------------------------------------ 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 var banned bool
err := db.QueryRow(ctx, "select banned_at is not null from users where id = $1", userID).Scan(&banned) 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 // ------------------------------------------------------------------ list
@@ -151,7 +155,9 @@ func (s *server) handlePlayers(w http.ResponseWriter, r *http.Request) {
serverError(w, err) serverError(w, err)
return 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) s.render(w, "pl-list", p)
return 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 // catChart is a small chart of one value per category (drill tiers, grades, levels), saved
// against the draft. Labels name the draft's categories. // 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, ch := &Chart{Title: title, YLabel: ylabel, LogY: logY, YFmt: yf, Width: 360, Height: 200,
XFmt: func(v float64) string { XFmt: func(v float64) string {
if i := int(v) - 1; i >= 0 && i < len(labels) && float64(i+1) == v { 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} { for k, vals := range [][]float64{saved, draft} {
s := ChartSeries{Name: []string{"Saved", draftName}[k], Class: []string{savedClass, draftClass}[k]} s := ChartSeries{Name: []string{"Saved", draftName}[k], Class: []string{savedClass, draftClass}[k]}
for i, v := range vals { 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) ch.Series = append(ch.Series, s)
} }
@@ -450,9 +450,10 @@ func curves(saved, draft *GameConfig, ls, ld Ladder, draftName string) (tiers, g
} }
return out 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 { 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, 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):] } num := func(v float64) string { return formatCompact(v)[len(currency):] }
tiers = []*Chart{ 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), tierChart("Units per second", "on a moon of its grade", true, func(t tier) float64 { return t.perSec }, num),
} }
var glabels []string gradesOf := func(c *GameConfig) []string {
for _, g := range draft.MoonGrades { var out []string
glabels = append(glabels, g.Grade) 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 { gvals := func(l Ladder, f func(GradeStat) float64) []float64 {
var out []float64 var out []float64
@@ -473,8 +485,8 @@ func curves(saved, draft *GameConfig, ls, ld Ladder, draftName string) (tiers, g
} }
return out return out
} }
gtip := func(i int, v float64) string { gtip := func(k, i int, v float64) string {
return fmt.Sprintf("%s: %s", glabels[min(i, len(glabels)-1)], formatCompact(v)) return fmt.Sprintf("%s: %s", gname(k, i), formatCompact(v))
} }
grades = []*Chart{ grades = []*Chart{
catChart("Moon price", "₥", true, glabels, gvals(ls, func(g GradeStat) float64 { return g.Price }), 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), 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 }), 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) }, gvals(ld, func(g GradeStat) float64 { return g.Mean }), func(v float64) string { return fmt.Sprintf("%.2g×", v) },
func(i int, v float64) string { func(k, i int, v float64) string {
if i < len(ld.Stats) { st := []Ladder{ls, ld}[k].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) 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) return fmt.Sprintf("%.2f×", v)
}, draftName), }, draftName),
@@ -505,14 +518,14 @@ func curves(saved, draft *GameConfig, ls, ld Ladder, draftName string) (tiers, g
} }
for _, kind := range trackKinds { for _, kind := range trackKinds {
for _, t := range td { for _, t := range td {
vs, _ := levelVals(ls, t.code, kind) vs, ss := levelVals(ls, t.code, kind)
vd, steps := levelVals(ld, t.code, kind) vd, sd := levelVals(ld, t.code, kind)
var lab []string var lab []string
for i := range max(len(vs), len(vd)) { for i := range max(len(vs), len(vd)) {
lab = append(lab, fmt.Sprint(i+1)) 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 { ch := catChart(t.code+" "+kind, "min to pay back", true, lab, vs, vd, minutesText, func(k, i int, v float64) string {
if i < len(steps) { if steps := [][]Purchase{ss, sd}[k]; i < len(steps) {
st := steps[i] 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)) 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") 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 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))) r.Traits = append(r.Traits, fmt.Sprintf("Rare: %s-rich · 1 in %d moons", rare.Resource, oneIn(rareShare)))
} }
return r 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") restoreRefused(w, "That save code doesn't match any miner")
return "" 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) // (422, not 403: a client shows 403 as its own player suspended)
restoreRefused(w, "That miner is suspended") restoreRefused(w, "That miner is suspended")
return "" 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 { if ms, _ := strconv.Atoi(os.Getenv("MM_API_DELAY_MS")); ms > 0 && s.dev {
handler = delayGame(handler, time.Duration(ms)*time.Millisecond) 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{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: logRequests(secureHeaders(limitBody(handler))), Handler: logRequests(secureHeaders(limitBody(handler))),
@@ -99,7 +99,11 @@ func serve(ctx context.Context) error {
WriteTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * 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() { go func() {
defer close(done)
<-ctx.Done() <-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -109,6 +113,7 @@ func serve(ctx context.Context) error {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err return err
} }
<-done
return nil 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 // 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". // as its IPv4, no zone, every loopback address (127.0.0.0/8, ::1) as "loopback", and an
// Anything unparsable is kept as it is. // IPv6 address as its /64 (one client holds a whole /64). Anything unparsable is kept as
// it is.
func normalIP(s string) string { func normalIP(s string) string {
if host, _, err := net.SplitHostPort(s); err == nil { if host, _, err := net.SplitHostPort(s); err == nil {
s = host s = host
@@ -271,6 +277,9 @@ func normalIP(s string) string {
if ip.IsLoopback() { if ip.IsLoopback() {
return "loopback" return "loopback"
} }
if ip.Is6() {
return netip.PrefixFrom(ip, 64).Masked().String()
}
return ip.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 var wells []*simMoon // unreserved supply by moon
left := 0.0 // total unreserved supply left := 0.0 // total unreserved supply
moons := 0 moons := 0
top := 0 // the dearest grade bought
for _, id := range c.StartingTools { for _, id := range c.StartingTools {
if i, ok := itemIndex[id]; ok { if i, ok := itemIndex[id]; ok {
tools = append(tools, simTool{item: i}) tools = append(tools, simTool{item: i})
@@ -582,6 +583,7 @@ func simulate(c *GameConfig) *SimResult {
break break
} }
credits -= moonPrice credits -= moonPrice
top = max(top, c.gradeIndex(g.Grade))
// Wells in units at the average price, which is what the sim sells at. // Wells in units at the average price, which is what the sim sells at.
kind := c.Moons[rng.IntN(len(c.Moons))] kind := c.Moons[rng.IntN(len(c.Moons))]
var moon []float64 var moon []float64
@@ -605,7 +607,11 @@ func simulate(c *GameConfig) *SimResult {
// either track for one owned tool of each kind; or an advisor. // either track for one owned tool of each kind; or an advisor.
now := income(advisors) now := income(advisors)
breakEven := func(cost, gain float64) Hours { 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 { type choice struct {
kind string // tool, upgrade, advisor kind string // tool, upgrade, advisor
@@ -661,8 +667,10 @@ func simulate(c *GameConfig) *SimResult {
} }
consider(ch, income(append(slices.Clone(advisors), a.Name))-now) consider(ch, income(append(slices.Clone(advisors), a.Name))-now)
} }
// Keep enough for the next moon: with dry wells and no credits, nothing earns. // Keep enough for the next moon: with dry wells and no credits, nothing earns. The
if best.kind == "" || credits-best.cost < nextGrade(credits).Price { // 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 break
} }
credits -= best.cost 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 var all []Suggestion
for i := range s.Moons { for i := range s.Moons {
moon := &s.Moons[i] moon := &s.Moons[i]
remain := s.project(moon).Remain
for _, w := range moon.Wells { for _, w := range moon.Wells {
if w.Supply <= 0 { if w.Supply <= 0 {
continue 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) _, yield := s.drill(*t, item, w.Resource)
credits := 0.0 credits, got := 0.0, 0.0
for j, take := range moon.split(Stake{WellID: w.ID, Amount: yield, Reach: m.Reach}, moon.supply()) { for j, take := range moon.split(Stake{WellID: w.ID, Amount: yield, Reach: m.Reach}, remain) {
credits += take * s.sellPrice(moon.Wells[j].Resource) credits += take * s.sellPrice(moon.Wells[j].Resource)
got += take
}
if got <= 1e-9 {
continue
} }
row := s.wellRow(moon, w, "") row := s.wellRow(moon, w, "")
all = append(all, Suggestion{MoonID: moon.ID, MoonName: moon.Name, Well: 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 return a.Supply > b.Supply
}) })
var bestRate, bestSupply float64 // The labels compare the wells shown; "highest value" only when their values differ.
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
all = all[:min(3, len(all))] 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 { for i := range all {
sg := &all[i] sg := &all[i]
switch { 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() { async function analyze() {
const pane = $("#tab-analysis"); const pane = $("#tab-analysis");
const seq = ++analyzeSeq;
try { try {
const raw = JSON.stringify(draft); const raw = JSON.stringify(draft);
const res = await post("/utils/economy/analyze", { raw }); 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]"); const simBox = pane.querySelector("[data-sim]");
if (simBox && simRun.raw === raw) simBox.innerHTML = simRun.html; if (simBox && simRun.raw === raw) simBox.innerHTML = simRun.html;
else if (simBox && new URLSearchParams(location.search).has("sim")) simBox.querySelector("[data-sim-run]")?.click(); else if (simBox && new URLSearchParams(location.search).has("sim")) simBox.querySelector("[data-sim-run]")?.click();
@@ -923,6 +928,7 @@ function init() {
pending.ref = ref; pending.ref = ref;
} }
} catch (err) { } catch (err) {
if (seq !== analyzeSeq) return;
pane.innerHTML = `<p class="eco-bad">Analysis failed: ${esc(err.message)}</p>`; pane.innerHTML = `<p class="eco-bad">Analysis failed: ${esc(err.message)}</p>`;
} }
} }
@@ -956,7 +962,11 @@ function init() {
btn.setAttribute("aria-busy", "true"); btn.setAttribute("aria-busy", "true");
btn.disabled = true; btn.disabled = true;
try { 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) { if (body.errors) {
mapErrors(body.errors); mapErrors(body.errors);
status(body.errors.join(" · "), true); status(body.errors.join(" · "), true);
+1 -1
View File
@@ -72,7 +72,7 @@
<figure class="lo-chart frame"> <figure class="lo-chart frame">
<figcaption class="label">Credits per day · max {{cur .MaxCredits}} <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> </figcaption>
<div class="lo-bars"> <div class="lo-bars">
{{range .Days}} {{range .Days}}