Compare commits

..
16 Commits
Author SHA1 Message Date
JosephFerano 9b6b0b1204 Merge branch 'equip-rows' 2026-10-22 20:11:26 +07:00
JosephFeranoandClaude Opus 5.5 8058077454 Client: Moons content as wide as a card grid (wide windows fit 5 well cards); a well card's + Deploy sends multi-well drills too (one way to place every drill); the sale row's label and offer keep their size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:11:26 +07:00
JosephFeranoandClaude Opus 5.5 4a060f8a91 Equipment is a table: a row per drill (image, name with copy number and levels, status, yield and cycle with level pips, each track's Upgrade and Sell under them, short notes kept under those); a row opens the drill panel, Sell asks in place, before the Store the upgrades and Sell stay hidden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:10:00 +07:00
JosephFeranoandClaude Opus 5.5 17a2cfbfa5 Multi-well drills mine their own well then the next ones in the moon's order (wrapping round), not the richest; an empty or soon-tapped well is passed over; the client predicts the same
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:45:41 +07:00
JosephFeranoandClaude Opus 5.5 c59b8d9140 Refinery: batches start at 100; upgrades 250, 500, 1000, 2500, 5000, 10000 at the same prices
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:39:56 +07:00
JosephFeranoandClaude Opus 5.5 ba77879350 Equipment lists every tool on its own, copies too; upgrade and sell act on that tool (no idle-copy swap)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:37:57 +07:00
JosephFeranoandClaude Opus 5.5 6363a40ba3 Client: recipe cards show what you own as a heading (OWNED over a big number in the item's color, at 0 too); a collect's motes fly into it and it counts up
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:30:51 +07:00
JosephFeranoandClaude Opus 5.5 15f8ad539b Refinery: shorter batches: bulkLog 1.0 (was 1.83) and seconds Dusk 1, Cryo 2, Plasma 2, Void 3, Stellar 3: a full batch of 50 Cryo Cells takes about 10 s (was 16), 5000 about 19 s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:22:11 +07:00
JosephFeranoandClaude Opus 5.5 dd028afd3e Balance: wider steps: grades and their drills scaled together per grade (C x2, B x3, A x5, S x8: prices, wells, yields, upgrades), so the jump to the next grade outgrows income; refined items gain about 20% over inputs (was 40-100%). Sim: first B 30m (was 15m), S 2h33 (was 1h04)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:20:44 +07:00
JosephFeranoandClaude Opus 5.5 4fbef903ec Client: recipe cards put Max (as many as you can) full width and primary, Refine beside the quantity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:13:23 +07:00
JosephFeranoandClaude Opus 5.5 f79ac6f3b9 Refinery: batches start at 50 (C moons hold far more now); upgrades 100, 250, 500, 1000, 2500, 5000 for 6k to 15M
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:12:34 +07:00
JosephFeranoandClaude Opus 5.5 844b181644 Refinery: Void Core and Stellar Lattice show (and refine) once a B moon or higher is bought (refined item unlockGrade; onboarding flags grade:<G>, backfilled by migration 021)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:11:56 +07:00
JosephFeranoandClaude Opus 5.5 6f90df026a Client: the main panel's entrance replays when the section changes, and the moon only on Moons: Claim all finishing a moon (the view moves to another) no longer replays the Refinery's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:07:10 +07:00
JosephFeranoandClaude Opus 5.5 0091d2a099 Client: why Sell is off shows in place, not only in a tooltip (the drill panel: "Still mining. Sell it once it's idle" for its note; the moon sale: a line under its row)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:05:07 +07:00
JosephFeranoandClaude Opus 5.5 55acf50692 Client: Sell moon (destructive) moves to the foot of the side pane, compact there; a tray drill deploys straight to the selected well on click (no picking); no Redrill on a well its drills already take in full
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:55:51 +07:00
JosephFeranoandClaude Opus 5.5 8b07425d25 Client: a well card's ready drills show Claim only (no Redeploy) when claiming leaves the well tapped out or its rest taken by other drills; the same for the multi-well card and the moon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:46:40 +07:00
28 changed files with 883 additions and 429 deletions

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
-- on top instead (the web's card). card.grid lays cards out in columns (as many as -- on top instead (the web's card). card.grid lays cards out in columns (as many as
-- card_col_min allows, like the web's grid; centered and capped by the caller), equal -- card_col_min allows, like the web's grid; centered and capped by the caller), equal
-- heights per row, and -- heights per row, and
-- registers them for the panel entrance (g.frames, staggered). Store, Equipment and -- registers them for the panel entrance (g.frames, staggered). Store and
-- Advisors use it; the Refinery's recipes too (opts media = 0: no image). -- Advisors use it; the Refinery's recipes too (opts media = 0: no image).
-- --
-- y = card.grid(g, parent, x, y, inner, items, function(c, item) -- y = card.grid(g, parent, x, y, inner, items, function(c, item)
+13 -23
View File
@@ -202,31 +202,21 @@ end
-- earlier ones left. Wells are indexes into moon.wells (the server's order). -- earlier ones left. Wells are indexes into moon.wells (the server's order).
local EPS = 1e-9 local EPS = 1e-9
-- The wells a stake with this reach mines: its anchor first, then the richest (ties by -- The wells a stake with this reach mines: its anchor first, then the next wells in the
-- order), leaving out empty ones. -- moon's order (wrapping round), leaving out empty ones (game.go coverWells).
local function cover_wells(anchor, supply, reach) local function cover_wells(anchor, supply, reach)
local others = {} local n = #supply
for i, x in ipairs(supply) do local start = (anchor and anchor >= 1 and anchor <= n) and anchor or 1
if i ~= anchor and x > 0 then
others[#others + 1] = i
end
end
table.sort(others, function(a, b)
if supply[a] ~= supply[b] then
return supply[a] > supply[b]
end
return a < b
end)
local out = {} local out = {}
if anchor and supply[anchor] and supply[anchor] > 0 then local want = math.max(1, reach or 1)
out[1] = anchor for k = 0, n - 1 do
end if #out >= want then
for _, i in ipairs(others) do break
out[#out + 1] = i end
end local i = (start - 1 + k) % n + 1
local n = math.min(#out, math.max(1, reach or 1)) if supply[i] > 0 then
for i = #out, n + 1, -1 do out[#out + 1] = i
out[i] = nil end
end end
return out return out
end end
+2 -2
View File
@@ -8,8 +8,8 @@
-- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>", -- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>",
-- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells -- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel" / -- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel" /
-- "dialog:<...>", "list:active|past", equipment cards "drill:<tool id>", buttons that ask -- "dialog:<...>", "list:active|past", equipment rows "drill:<tool id>", buttons that ask
-- first "confirm:<action>", the gear "settings" (its menu: "mute", "theme", "motion", -- first "confirm:<action>" (a row's Sell "confirm:sell-tool:<tool id>"), the gear "settings" (its menu: "mute", "theme", "motion",
-- "grant", "menu:help", "menu:save-code", "menu:restore"), "idle-copy", "better-well". A -- "grant", "menu:help", "menu:save-code", "menu:restore"), "idle-copy", "better-well". A
-- component given opts.id uses that instead. -- component given opts.id uses that instead.
-- --
+1 -1
View File
@@ -171,7 +171,7 @@ M.size = {
market_max = 1320, -- the market's (quotes, detail and ticket) market_max = 1320, -- the market's (quotes, detail and ticket)
market_panel_max = 460, -- tallest the market's detail and ticket panels get market_panel_max = 460, -- tallest the market's detail and ticket panels get
text_max = 720, -- widest a paragraph gets text_max = 720, -- widest a paragraph gets
cards_max = 1400, -- widest a card grid gets (Store, Equipment, Advisors), centered cards_max = 1400, -- widest a card grid gets (Store, Advisors), centered
card_col_min = 250, -- narrowest card column (the web's minmax(250px, 1fr)); more as it widens card_col_min = 250, -- narrowest card column (the web's minmax(250px, 1fr)); more as it widens
card_min_w = 420, -- narrowest card with the image beside the text (narrower: image on top) card_min_w = 420, -- narrowest card with the image beside the text (narrower: image on top)
panel_pad = 14, panel_pad = 14,
+9 -5
View File
@@ -180,7 +180,7 @@ M.button_looks = setmetatable({}, { __mode = "k" })
-- A button's nodes (components/button.lua gives it behavior: hover, press, busy, clicks; -- A button's nodes (components/button.lua gives it behavior: hover, press, busy, clicks;
-- or H.action / H.link). opts: variant ("primary", "secondary", "redrill", "danger", -- or H.action / H.link). opts: variant ("primary", "secondary", "redrill", "danger",
-- "ghost"; theme.button), primary (= variant "primary"), small, disabled, w, h, price, -- "ghost"; theme.button), primary (= variant "primary"), small, disabled, w, h, price,
-- accent, on (a lit ghost), fill / edge / text (override the variant's colors). Returns -- accent, bare (the price right after the label, no "·"), on (a lit ghost), fill / edge / text (override the variant's colors). Returns
-- root, nodes, width. -- root, nodes, width.
function M.button(parent, label, x, y, opts) function M.button(parent, label, x, y, opts)
opts = opts or {} opts = opts or {}
@@ -199,12 +199,16 @@ function M.button(parent, label, x, y, opts)
local parts = { { text, M.text_width(text) } } local parts = { { text, M.text_width(text) } }
if opts.price then if opts.price then
local accent = opts.accent or v.price local accent = opts.accent or v.price
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
gui.set_color(sep, M.color_spec(tc))
local sym = M.text(root, theme.currency, { font = "sym", px = px - 1, color = accent }, 0, 0) local sym = M.text(root, theme.currency, { font = "sym", px = px - 1, color = accent }, 0, 0)
local num = M.text(root, M.money(opts.price), { font = "mono", px = px - 1, color = accent }, 0, 0) local num = M.text(root, M.money(opts.price), { font = "mono", px = px - 1, color = accent }, 0, 0)
parts[#parts + 1] = { sep, M.text_width(sep), 8 } if opts.bare then -- (no "·" between: "+ ₥290")
parts[#parts + 1] = { sym, M.text_width(sym), 8 } parts[#parts + 1] = { sym, M.text_width(sym), 5 }
else
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
gui.set_color(sep, M.color_spec(tc))
parts[#parts + 1] = { sep, M.text_width(sep), 8 }
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
end
parts[#parts + 1] = { num, M.text_width(num), 1 } parts[#parts + 1] = { num, M.text_width(num), 1 }
end end
local content = 0 local content = 0
+30 -6
View File
@@ -3,13 +3,15 @@
-- well's facts (supply, what the drills leave, price, its drills and their times), the -- well's facts (supply, what the drills leave, price, its drills and their times), the
-- moon's when no well is picked, or what the section puts there (a section module's -- moon's when no well is picked, or what the section puts there (a section module's
-- aside / aside_below, see core/main.lua), always under the viewport. On the Moons -- aside / aside_below, see core/main.lua), always under the viewport. On the Moons
-- section it only shows: every action is in the main panel (moons.lua's well cards). -- section it only shows, but for destructive actions (Sell moon, at its foot): every
-- other action is in the main panel (moons.lua's well cards).
local theme = require("modules.theme") local theme = require("modules.theme")
local ui = require("modules.ui") local ui = require("modules.ui")
local moon_view = require("modules.moon.moon") 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 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")
@@ -104,6 +106,20 @@ local M11 = { font = "mono", px = 11, color = "muted" }
local M12 = { font = "mono", px = 12, color = "muted" } local M12 = { font = "mono", px = 12, color = "muted" }
local S12 = { font = "sans", px = 12, color = "muted" } local S12 = { font = "sans", px = 12, color = "muted" }
-- Selling the moon (once the store is open): a line, then the sale (moon_sale.lua). Tree
-- pieces for the foot of the well or moon facts.
local function sale_kids(g, c, moon, inner, H)
if not (g.state.unlocked.store and moon.sale) then
return {}
end
return {
space(0, 14),
made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end),
space(0, 12),
moon_sale.box(g, H, c, moon, inner),
}
end
-- A drill on the well (34 tall; it slides in when the drill was just deployed): -- A drill on the well (34 tall; it slides in when the drill was just deployed):
-- thumbnail, name and track levels; what it gets; time left or Ready. -- thumbnail, name and track levels; what it gets; time left or Ready.
local function drill_line(g, c, st, d, H, enters) local function drill_line(g, c, st, d, H, enters)
@@ -300,7 +316,10 @@ local function well_panel(g, root, pad, top, inner, H)
add(space(0, 6)) add(space(0, 6))
end end
-- (no buttons here: every action on the well is on its card in the main panel) -- (no buttons here but Sell moon: every action on the well is on its card)
for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
add(k)
end
local tree = col({ width = inner }, kids) local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, pad, top), c) fg.build(flex.solve(tree, pad, top), c)
for _, f in ipairs(enters) do for _, f in ipairs(enters) do
@@ -369,10 +388,12 @@ local function moon_status(g, c, x, y, inner, H)
fact(H, "Ready to claim", tostring(done), { color = done > 0 and "ready" or "text" }), fact(H, "Ready to claim", tostring(done), { color = done > 0 and "ready" or "text" }),
fact(H, "Idle drills", tostring(idle)), fact(H, "Idle drills", tostring(idle)),
} }
if st.unlocked.store and moon.sale then for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
kids[#kids + 1] = fact(H, "Sells for", moon.sale.total, { credits = true }) kids[#kids + 1] = k
end end
fg.build(flex.solve(col({ width = inner }, kids), x, y), c) local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, x, y), c)
return tree.y + tree.h
end end
-- A heading (h2) and a line under the viewport. -- A heading (h2) and a line under the viewport.
@@ -437,7 +458,10 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
elseif moon and moon.finished then elseif moon and moon.finished then
caption(root, pad, cy, inner, moon.name, "Mined out. Nothing left to dig.") caption(root, pad, cy, inner, moon.name, "Mined out. Nothing left to dig.")
elseif moon then elseif moon then
moon_status(g, root, pad, cy, inner, H) -- (scrolls like the well panel: Sell moon at its foot)
local top = cy - 8
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
elseif mod and mod.aside_below and st[section] then elseif mod and mod.aside_below and st[section] then
mod.aside_below(g, root, pad, cy, inner, limit, H) mod.aside_below(g, root, pad, cy, inner, limit, H)
elseif section == "new-moon" then elseif section == "new-moon" then
+4 -2
View File
@@ -87,14 +87,16 @@ end
---------------------------------------------------------------- build ---------------------------------------------------------------- build
-- Entrances (enter.js): the main panel when the section or moon changes, the aside when -- Entrances (enter.js): the main panel when the section (or on Moons the moon) changes, the aside when
-- the well changes (or the drill: the client's drill panel is in the aside); nothing else -- the well changes (or the drill: the client's drill panel is in the aside); nothing else
-- in the view (pick, list, page, adv) makes an entrance. Links keep the moon like the -- in the view (pick, list, page, adv) makes an entrance. Links keep the moon like the
-- web's (View.ToSection), else the server's first-moon default would change it. Both on -- web's (View.ToSection), else the server's first-moon default would change it. Both on
-- the first state and after the reveal closes (g.enter_all). -- the first state and after the reveal closes (g.enter_all).
function enter(g) function enter(g)
local v = g.view local v = g.view
local main_key = v.section .. "|" .. (v.moon or "") -- (the moon only for the Moons section: elsewhere an action that moves the view's moon,
-- Claim all finishing one, mustn't replay the section's entrance)
local main_key = v.section .. "|" .. (v.section == "moons" and (v.moon or "") or "")
local well_key = (v.moon or "") .. "|" .. (v.well or "") .. "|" .. (v.drill or "") -- (the drill panel is in the aside) local well_key = (v.moon or "") .. "|" .. (v.well or "") .. "|" .. (v.drill or "") -- (the drill panel is in the aside)
local all = g.enter_all or not g.entered local all = g.enter_all or not g.entered
if g.reveal:hiding() then if g.reveal:hiding() then
+338 -116
View File
@@ -1,12 +1,11 @@
-- The Equipment section (state.equipment): a card per tool (copies of the same type and -- The Equipment section (state.equipment): a table, a row per tool (copies too), with its
-- track levels share one) with its image, levels chip, status, stats (yield, cycle) and -- image, name, copy number and levels, status, and its two upgrade tracks in columns (value
-- its two upgrade tracks side by side (tracks.go): each track's name and level, its value -- now ▸ after the next level, stat_up violet; level pips), each track's Upgrade button (or
-- now ▸ after the next level (stat_up violet, apart from money), the Upgrade button (or -- Max) and Sell under them, notes under those (the table's comment, below). A row opens the
-- Max level in its place) and a need line kept whether it shows, so the buttons never -- drill panel (view drill=<tool id>) in the aside: stats, the two tracks, each track's
-- move (clicker rule). A card opens the drill panel (view drill=<tool id>) in the aside: -- ladder, idle copies and their best well, and selling the drill. Before the Store is
-- stats, the two tracks, each track's ladder, idle copies and their best well, and -- unlocked (onboarding) rows and panel leave out the Store's parts (upgrades, selling): the
-- selling the drill. Before the Store is unlocked (onboarding) cards and panel leave out -- panel is stats, ladders and idle copies.
-- the Store's parts (upgrades, selling): the panel is stats, ladders and idle copies.
local theme = require("modules.theme") local theme = require("modules.theme")
local ui = require("modules.ui") local ui = require("modules.ui")
@@ -14,8 +13,8 @@ local tooltip = require("modules.tooltip")
local cards = require("screens.game.cards") local cards = require("screens.game.cards")
local scroll = require("components.scroll") local scroll = require("components.scroll")
local store = require("screens.game.store") local store = require("screens.game.store")
local chip = require("components.chip") local row_c = require("components.row")
local card = require("components.card") local blocks = require("modules.blocks")
local flex = require("modules.flex") local flex = require("modules.flex")
local fg = require("modules.flex_gui") local fg = require("modules.flex_gui")
@@ -54,14 +53,6 @@ local function stats_box(st, parent, t)
return row({ wrap = true, column_gap = 16 }, kids) return row({ wrap = true, column_gap = 16 }, kids)
end end
-- The same at (x, y), w wide (inside a card block); returns the height used.
local function stats(st, parent, t, x, y, w)
local tree = stats_box(st, parent, t)
tree.style.width = w
fg.build(flex.solve(tree, x, y), parent)
return tree.h
end
-- A track's value: "+5" (yield per cycle) or "2.6s" (cycle). -- A track's value: "+5" (yield per cycle) or "2.6s" (cycle).
local function track_value(tr, v) local function track_value(tr, v)
return tr.kind == "cycle" and ui.cycle(v) or ("+" .. ui.money(v)) return tr.kind == "cycle" and ui.cycle(v) or ("+" .. ui.money(v))
@@ -148,114 +139,330 @@ local function tracks_box(self, H, st, parent, t, w)
return row({ width = w, align = "start" }, kids) return row({ width = w, align = "start" }, kids)
end end
-- The same at (x, y) (a card's footer); returns the height used. ---------------------------------------------------------------- the table
local function tracks(self, H, st, parent, t, x, y, w)
local tree = tracks_box(self, H, st, parent, t, w) -- One row per tool (copies are rows of their own), fixed columns: image | name, with
fg.build(flex.solve(tree, x, y), parent) -- "#<copy> · Y2 C1" under it (the copy's number among tools of its type, its levels) |
return tree.h -- Status | Yield / claim | Cycle | Sell. Line 1: each track's value ▸ next and its level
-- pips. Once the Store is open, line 2: each track's Upgrade ("+ ₥290"; Max at the top)
-- under its column and Sell in the last; line 3: a short note under each (credits short,
-- the parts it uses, why Sell is off), kept whether it shows. Every row is the same height
-- (row_h), so buttons never move (clicker rule). Narrow tables (below WIDE): Status leaves
-- its column for line 2, under the name. A click on the row outside its buttons opens the
-- drill panel. Sell asks in place (cards.ask_*): Confirm and Cancel take line 2's right
-- end (Cancel on Sell's spot), the question line 3.
local PADX, PADY = 12, 9
local LINE1, NOTE = 40, 15
local WIDE = 640 -- (narrower: no Status column)
local TABLE_MAX = 1100
local HEAD = { font = "sans_semi", px = 10, color = "muted", tracking = 0.1, upper = true }
local WHERE = { font = "sans", px = 13, color = "text_2" }
local NOTE_S = { font = "sans", px = 11, color = "muted" }
-- Column widths for the table's width (narrow ones tighter, so names have room).
local function geometry(inner)
local wide = inner >= WIDE
return {
wide = wide,
gap = wide and 12 or 10,
media_w = wide and 48 or 40, media_h = wide and 40 or 34,
status = wide and math.min(230, math.floor(inner * 0.22)) or 0,
track = wide and 124 or 90,
sell = wide and 150 or 60,
name_px = wide and 14 or 13,
}
end end
-- A tool's status (a 20 tall row at (x, y - 10), w wide): where its drill is (its local function row_h(open, wide)
-- resource dot, "<moon> · <resource>", returned as a link target) and time left or Ready, if open then
-- or how many copies are idle. return PADY * 2 + LINE1 + 6 + S.button_sm_h + 2 + NOTE
local function status(self, H, st, parent, t, x, y, w) end
local d = t.stake return PADY * 2 + LINE1 + (wide and 0 or 26)
local kids, where_link = {}, nil end
if d then
local moon = find(st.moons, "id", d.moonId) -- Column captions.
local where = (moon and (moon.name .. " · ") or "") .. d.resource local function header(c, geo)
local lbl = fg.label(parent, where, { font = "sans", px = 13, color = "text_2" }, 20, local function cap(text, w)
{ fit = true, shrink = 1, max_width = w - 14 - 110 }) return fg.label(c, text, HEAD, 14, { width = w, shrink = 0, fit = true })
lbl.done = function(n, b) end
where_link = { node = n, x = x, y = y, w = b.w + 14, view = { section = "moons", moon = d.moonId, well = d.wellId } } return row({ height = 14, gap = geo.gap, padding = { 0, PADX, 0, PADX }, align = "center" }, {
fg.label(c, geo.wide and "Drill" or "Drill · Status", HEAD, 14, { grow = 1, shrink = 1, basis = 0, fit = true }),
geo.wide and cap("Status", geo.status) or nil,
cap("Yield / claim", geo.track), cap("Cycle", geo.track), space(geo.sell),
})
end
-- A track's value in the table: "12" (claim per cycle) or "2.4s".
local function cell_value(tr, v)
return tr.kind == "cycle" and ui.cycle(v) or ui.money(v)
end
-- Line 1 of a track's column: value ▸ next (the next only once the Store is open), its
-- level pips under it.
local function value_cell(c, tr, w, open)
if not tr then
return space(w)
end
local n = open and tr.next
return col({ width = w, shrink = 0 }, {
made({ height = 18, shrink = 0 }, function(p, x, y)
local now = ui.text(p, cell_value(tr, tr.value), { font = "mono", px = 13, color = "text" }, x, y + 9)
if n then
local ax = x + ui.text_width(now) + 5
ax = ax + cards.arrow(p, ax, y + 9) + 4
local nx = ui.text(p, cell_value(tr, n.value), { font = "mono_semi", px = 13, color = "stat_up" }, ax, y + 9)
tooltip.add(nx, "After the upgrade")
end
end),
space(0, 3),
made({ height = 9, shrink = 0 }, function(p, x, y, bw)
local m = math.max(1, tr.max)
local pw = math.max(3, math.min(12, math.floor((bw - 3 * (m - 1)) / m)))
local hold = ui.rect(p, x, y, (pw + 3) * m - 3, 9, "none")
for i = 1, m do
ui.rect(hold, (i - 1) * (pw + 3), 2, pw, 5, i <= tr.level and "cyan" or "line_hi")
end
tooltip.add(hold, tr.name .. " " .. tr.level .. "/" .. tr.max)
end),
})
end
-- Line 2 of a track's column: its Upgrade button ("+ ₥290"), or Max at the top.
local function up_button(self, H, c, t, tr, w)
local n = tr and tr.next
if not n then
return box({ width = w, height = S.button_sm_h, shrink = 0, align = "center", justify = "center" }, {
tr and fg.label(c, "Max", { font = "sans_semi", px = 12, color = "muted", tracking = 0.12, upper = true }, 14) or nil,
})
end
local ok = n.canAfford
local tip = n.label
if not ok and (n.blockedText or "") ~= "" then
tip = tip .. ": " .. n.blockedText
elseif #(n.parts or {}) > 0 then
local ps = {}
for _, pt in ipairs(n.parts) do
ps[#ps + 1] = ui.money(pt.need) .. " " .. pt.name
end end
local right tip = tip .. ": " .. theme.currency .. ui.money(n.price) .. " + " .. table.concat(ps, ", ")
if d.ready then end
right = fg.label(parent, "Ready", { font = "sans_semi", px = 13, color = "ready" }, 20) return fg.button(c, "+", { small = true, bare = true, price = n.price, disabled = not ok }, function(b, nodes)
else tooltip.add(b, tip)
right = fg.label(parent, ui.dur(d["end"] - H.now(self)) .. " left", { font = "mono", px = 12, color = "muted" }, 20) if ok then
right.done = function(n) cards.pulse(nodes)
H.tick_text(self, n, d["end"], function(left) H.action(self, b, nodes, "upgrade", { tool = t.id, track = tr.kind })
return ui.dur(left) .. " left" end
end, "due") end, { width = w })
end
-- A text in a w-wide slot of line 3, centered (right: at its right end), made a little
-- smaller to fit, else cut with … (one with ₥ only shrinks: rich text can't be cut).
local function note_text(text, color, w, tip, right)
return made({ width = w, height = NOTE, shrink = 0 }, function(p, x, y, bw)
local n, tw = ui.label(p, text, { font = "sans", px = 11, color = color }, 0, 0)
local rich = ui.needs_rich(text)
local k = math.min(1, bw / math.max(1, tw))
if k < 1 then
k = rich and k or math.max(k, 0.85)
local sc = gui.get_scale(n)
gui.set_scale(n, vmath.vector3(sc.x * k, sc.y * k, sc.z))
if not rich then
ui.fit_text(n, bw)
tw = ui.text_width(n) / k
end end
end end
kids = { local lx = right and (bw - tw * k) or math.floor((bw - tw * k) / 2)
made({ width = 8, height = 8, shrink = 0 }, function(p, rx, ry) ui.rect(p, rx, ry, 8, 8, d.resource) end), gui.set_position(n, vec(x + math.floor(lx), -(y + NOTE / 2)))
space(6), lbl, fg.fill(), right, tooltip.add(n, tip)
t.count > 1 and fg.label(parent, " · all " .. t.count .. " drilling", { font = "sans", px = 13, color = "muted" }, 20) or nil, end)
}
else
local busy = t.count - t.idle
kids = { fg.label(parent, busy > 0 and (t.idle .. " idle · " .. busy .. " drilling") or "Idle", { font = "sans", px = 13, color = "muted" }, 20) }
end
fg.build(flex.solve(row({ width = w, height = 20, align = "center" }, kids), x, y - 10), parent)
return where_link
end end
local function tool_card(self, H, st, c, t, open) -- Line 3 of a track's column: credits short (the server's shortText), else the parts the
c:image(t.image) -- next level uses ("+ 2 Dusk Alloy", "+ 2 parts"; red when short), else blank.
-- Name, ×count, levels chip on the right (none for a base tool). local function up_note(c, tr, w)
local tag = ui.levels_tag(t) local n = tr and tr.next
c:block(22, function(p, bw) local blank = space(w, NOTE)
local cnt = t.count > 1 and ("×" .. t.count) or nil blank.blank = true
fg.build(flex.solve(row({ width = bw, height = 16, align = "center" }, { if not n then
fg.label(p, t.name, { font = "sans_semi", px = 15, color = "text", tracking = 0.1, upper = true }, 16, { fit = true, shrink = 1 }), return blank
cnt and space(6) or nil,
cnt and fg.label(p, cnt, { font = "mono", px = 13, color = "muted" }, 16) or nil,
fg.fill(), space(8),
tag ~= "" and fg.inline(p, 16, function(q, x, y, probing)
local lv_chip, cwid = chip.new(q, tag:sub(2), x, y, { font = "mono", px = 11, text = "text_2", pad = 5 })
if not probing then
tooltip.add(lv_chip, "Yield level " .. (t.yieldLevel or 0) .. ", cycle level " .. (t.cycleLevel or 0))
end
return cwid
end) or nil,
}), 0, 0), p)
end)
c:block(t.reach > 1 and 22 or 28, function(p, bw)
local l = status(self, H, st, p, t, 0, 10, bw)
if l then
-- "<moon> · <resource>" links to that well (the web's row-link).
c:after_bind(function()
local hit = ui.rect(p, l.x, l.y - 9, l.w, 18, "none")
local b = H.link(self, hit, l.view, "well-of:" .. t.id)
b.hover.on_mouse_hover:subscribe(function(_, on)
gui.set_color(l.node, ui.color(on and "cyan_hi" or "text_2"))
end)
end)
end
end, 2)
local reach = cards.reach(st, t.reach)
if reach ~= "" then
c:text(reach .. " per cycle", "small", 24)
end end
c:block(function(p, bw) if n.blocked == "credits" and (n.shortText or "") ~= "" then
ui.rect(p, 0, 0, bw, 1, "line") return note_text(n.shortText, "danger", w, n.blockedText)
return 6 + stats(st, p, t, 0, 4, bw) + 4
end)
c.id = "drill:" .. t.id
c.click = function()
H.navigate(self, drill_view(self, t.id))
end end
if open then local parts = n.parts or {}
-- The two upgrade tracks: measured on a probe; the footer sits at the row's bottom. if #parts == 0 then
local probe = ui.rect(c.root, 0, 0, 1, 1, "none") return blank
local fh = tracks(self, { action = function() end }, st, probe, t, 0, 0, c.bw) end
gui.delete_node(probe) local short, ps = false, {}
c:footer(fh + 2, function(f, bw) for _, pt in ipairs(parts) do
tracks(self, H, st, f, t, 0, 2, bw) short = short or pt.have < pt.need
ps[#ps + 1] = ui.money(pt.need) .. " " .. pt.name .. " (you have " .. ui.money(pt.have) .. ")"
end
local text = #parts == 1 and ("+ " .. ui.money(parts[1].need) .. " " .. parts[1].name) or ("+ " .. #parts .. " parts")
return note_text(text, short and "danger" or "muted", w, "Parts: " .. table.concat(ps, ", "))
end
-- Where a mining tool is: its resource dot and moon (a link to the well), h tall. The
-- name may hold Greek letters (a rich label: it isn't cut).
local function where_box(self, H, c, st, t, d, h)
local moon = find(st.moons, "id", d.moonId)
local lbl = fg.label(c, moon and moon.name or d.resource, WHERE, h, { shrink = 1, fit = true })
local name_n
lbl.done = function(n)
name_n = n
end
return made({ height = h, shrink = 1, min_width = 40, padding_left = 14, align = "center" }, function(p, x, y, w)
ui.rect(p, x, y + math.floor(h / 2) - 4, 8, 8, d.resource)
local hit = ui.rect(p, x, y, w, h, "none")
tooltip.add(hit, (moon and (moon.name .. " · ") or "") .. d.resource)
-- (the web's row-link to that well; bound after the row, so it gets its clicks)
local b = H.link(self, hit, { section = "moons", moon = d.moonId, well = d.wellId }, "well-of:" .. t.id)
b.hover.on_mouse_hover:subscribe(function(_, on)
if name_n then
pcall(gui.set_color, name_n, ui.color(on and "cyan_hi" or "text_2"))
end
end) end)
end, { lbl })
end
-- Time left (ticking) or Ready, h tall.
local function left_box(self, H, c, d, h)
if d.ready then
return fg.label(c, "Ready", { font = "sans_semi", px = 13, color = "ready" }, h, { shrink = 0 })
end end
c.selected = t.selected and self.view.drill ~= "" local l = fg.label(c, ui.dur(d["end"] - H.now(self)) .. " left", { font = "mono", px = 12, color = "muted" }, h, { shrink = 0 })
l.done = function(n)
H.tick_text(self, n, d["end"], function(left)
return ui.dur(left) .. " left"
end, "due")
end
return l
end
-- A tool's status: Idle, or where it mines and time left / Ready (wide: two lines in its
-- column; narrow: one line). style: the box's size in its row.
local function status_box(self, H, c, st, t, wide, style)
local d = t.stake
if not d then
style.height = style.height or 18
return fg.label(c, "Idle", { font = "sans", px = 13, color = "muted" }, 18, style)
end
if wide then
style.direction = "column"
return col(style, {
row({ height = 18 }, { where_box(self, H, c, st, t, d, 18) }),
row({ height = 16, padding_left = 14 }, { left_box(self, H, c, d, 16) }),
})
end
style.height, style.align = 20, "center"
return row(style, { where_box(self, H, c, st, t, d, 20), space(8), left_box(self, H, c, d, 20) })
end
-- Sell's confirm words: the server's for the panel's drill (equipment.drill.confirm), the
-- same words for any other row (tracks.go sellText; the API sends them for that one only).
local function sell_confirm(st, t)
local d = st.equipment.drill
if d and d.toolId == t.id and d.confirm then
return d.confirm
end
local price = theme.currency .. ui.money(t.resale or 0)
return { title = "Sell drill", text = "Sell this " .. t.name .. ui.levels_tag(t) .. " for " .. price .. "?", ok = "Sell for " .. price, danger = true }
end
local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
local wide, gap = geo.wide, geo.gap
local trs = t.tracks or {}
local tag = ui.levels_tag(t)
local tagline = "#" .. copy .. (tag ~= "" and (" ·" .. tag) or "")
.. ((t.reach or 1) > 1 and (" · " .. cards.wells(st, t.reach)) or "")
local name = fg.label(c, t.name, { font = "sans_semi", px = geo.name_px, color = "text", tracking = wide and 0.08 or 0.04, upper = true }, 20,
{ fit = true, shrink_text = 0.8 })
name.done = function(n)
tooltip.add(n, t.name .. ": yield level " .. (t.yieldLevel or 0) .. ", cycle level " .. (t.cycleLevel or 0))
end
local lines = {
row({ height = LINE1, gap = gap, align = "center" }, {
made({ width = geo.media_w, height = geo.media_h, shrink = 0 }, function(p, x, y)
local mw, mh = geo.media_w, geo.media_h
local m = ui.rect(p, x, y, mw, mh, "bg")
ui.outline(m, 0, 0, mw, mh, "line")
cards.image(m, t.image, (mw - mh) / 2, 0, mh)
end),
col({ grow = 1, shrink = 1, basis = 0 }, {
name,
fg.label(c, tagline, { font = "mono", px = 12, color = "muted" }, 16, { fit = true }),
}),
wide and status_box(self, H, c, st, t, true, { width = geo.status, shrink = 0 }) or nil,
value_cell(c, trs[1], geo.track, open), value_cell(c, trs[2], geo.track, open),
space(geo.sell),
}),
}
local left = wide and fg.fill() or status_box(self, H, c, st, t, false, { grow = 1, shrink = 1, basis = 0 })
if open then
local why = (t.noSale or "") ~= "" and t.noSaleText or nil
local sid = "confirm:sell-tool:" .. t.id
local asking = not why and cards.asking(self, sid, conf, { tool = t.id })
local right_w = geo.track * 2 + geo.sell + gap * 2
lines[#lines + 1] = space(0, 6)
if asking then
local sell_w = fg.button_w(c, "Sell", { small = true, variant = "danger" })
local ok, cancel = cards.ask_buttons(self, c, conf, sell_w)
lines[#lines + 1] = row({ height = S.button_sm_h, gap = 6, align = "center" }, { left, ok, cancel })
lines[#lines + 1] = space(0, 2)
lines[#lines + 1] = row({ height = NOTE }, {
fg.fill(), box({ width = right_w, height = NOTE, shrink = 0 }, { cards.ask_text(conf, c, { lines = 1 }) }),
})
else
lines[#lines + 1] = row({ height = S.button_sm_h, gap = gap, align = "center" }, {
left,
up_button(self, H, c, t, trs[1], geo.track), up_button(self, H, c, t, trs[2], geo.track),
row({ width = geo.sell, shrink = 0, justify = "end" }, {
fg.button(c, "Sell", { small = true, disabled = why ~= nil, variant = "danger" }, function(b)
tooltip.add(b, why)
if not why then
cards.ask_button(self, b, conf, "sell-tool", { tool = t.id }, sid)
end
end),
}),
})
lines[#lines + 1] = space(0, 2)
-- (why Sell is off, right-aligned under it: over the Cycle column's note too
-- when that one is blank)
local cyc = up_note(c, trs[2], geo.track)
local wide_why = why and cyc.blank
lines[#lines + 1] = row({ height = NOTE, gap = gap }, {
fg.fill(), up_note(c, trs[1], geo.track),
not wide_why and cyc or nil,
why and note_text(why, "ready", wide_why and (geo.track + gap + geo.sell) or geo.sell, why, true)
or space(geo.sell, NOTE),
})
end
elseif not wide then
lines[#lines + 1] = space(0, 6)
lines[#lines + 1] = row({ height = 20, align = "center" }, { left })
end
local rh = row_h(open, wide)
local selected = t.selected and self.view.drill ~= ""
return col({ width = w, height = rh, padding = { PADY, PADX, PADY, PADX } }, lines, { make = function(p, x, y, bw, bh)
local slot = ui.rect(p, x, y, bw, bh, "none")
local body = ui.rect(slot, 0, 0, bw, bh, "cyan", 0)
row_c.bind(self, body, bw, bh, { selected = selected, id = "drill:" .. t.id, label = t.name, on_click = function()
H.navigate(self, drill_view(self, t.id))
end })
ui.rect(body, 0, bh - 1, bw, 1, "line")
return body
end })
end end
function M.build(self, root, px, py, w, h, H) function M.build(self, root, px, py, w, h, H)
local st = self.state local st = self.state
local eq = st.equipment local eq = st.equipment
local open = st.unlocked.store local open = st.unlocked.store
local area, x, inner, y = cards.page(self, root, px, py, w, h, "equipment") local area, x, inner, y = cards.page(self, root, px, py, w, h, "equipment", TABLE_MAX)
local c = area.content local c = area.content
local geo = geometry(inner)
y = fg.flow(c, x, y, inner, { col({ width = inner }, { -- (full width: Buy tools sits at its right edge) y = fg.flow(c, x, y, inner, { col({ width = inner }, { -- (full width: Buy tools sits at its right edge)
fg.label(c, "Equipment", "h1", 40), fg.label(c, "Equipment", "h1", 40),
fg.label(c, ui.plural(#st.tools, "tool") .. "." .. (open and " Each drill has two upgrade tracks: more per cycle, or shorter cycles." or ""), fg.label(c, ui.plural(#st.tools, "tool") .. "." .. (open and " Each drill has two upgrade tracks: more per cycle, or shorter cycles." or ""),
@@ -266,14 +473,28 @@ function M.build(self, root, px, py, w, h, H)
end, { position = "absolute", top = 8, right = x + inner - (w - px) }) or nil, -- (at the panel's top-right corner, past the capped column) end, { position = "absolute", top = 8, right = x + inner - (w - px) }) or nil, -- (at the panel's top-right corner, past the capped column)
#eq.tools == 0 and fg.label(c, "You don't own any tools yet.", "small", 16) or nil, #eq.tools == 0 and fg.label(c, "You don't own any tools yet.", "small", 16) or nil,
#eq.tools == 0 and space(0, 14) or nil, #eq.tools == 0 and space(0, 14) or nil,
#eq.tools > 0 and header(c, geo) or nil,
#eq.tools > 0 and space(0, 6) or nil,
}) }) }) })
y = card.grid(self, c, x, y, inner, eq.tools, function(cd, t) if #eq.tools > 0 then
tool_card(self, H, st, cd, t, open) ui.rect(c, x, y, inner, 1, "line_hi")
end, { key = function(t) return "eq:tool:" .. tostring(t.id) end, sig = function(t) y = y + 1
-- (kept while what it shows stays the same: modules/blocks) end
local seen, rh = {}, row_h(open, geo.wide)
for _, t in ipairs(eq.tools) do
seen[t.storeItemId] = (seen[t.storeItemId] or 0) + 1
local copy = seen[t.storeItemId]
local conf = sell_confirm(st, t)
local moon = t.stake and find(st.moons, "id", t.stake.moonId) local moon = t.stake and find(st.moons, "id", t.stake.moonId)
return { open, moon and moon.name or false, st.refined, st.credits, st.resources, st.catalogVersion, self.inflight, self.view } -- (kept while what it shows stays the same: modules/blocks)
end }) local sig = blocks.sig(x, y, inner, t, copy, conf, open, moon and moon.name or false, st.refined, st.credits,
st.resources, st.catalogVersion, self.inflight, self.view, self.ask and self.ask.id or false)
local ry = y
blocks.keep(self, "eq:tool:" .. tostring(t.id), sig, c, function(r)
fg.build(flex.solve(tool_row(self, H, c, st, t, copy, geo, inner, open, conf), x, ry), r)
end)
y = y + rh
end
area:finish(y + py) area:finish(y + py)
end end
@@ -488,8 +709,9 @@ function M.aside(self, root, w, h, H)
fg.label(c, "Sells for", { font = "sans", px = 15, color = "text" }, 16), space(6), fg.label(c, "Sells for", { font = "sans", px = 15, color = "text" }, 16), space(6),
credits_box(c, d.resale, { px = 15 }, 16), credits_box(c, d.resale, { px = 15 }, 16),
}), }),
-- (why Sell is off, in place of the note: not only in its tooltip)
asking and cards.ask_text(d.confirm, c, { lines = 3 }) or fg.measured(c, function(p, x, y, bw) asking and cards.ask_text(d.confirm, c, { lines = 3 }) or fg.measured(c, function(p, x, y, bw)
return cards.para(p, note, { font = "sans", px = 12, color = "muted" }, x, y, bw) return cards.para(p, why or note, { font = "sans", px = 12, color = why and "ready" or "muted" }, x, y, bw)
end), end),
space(0, 4), space(0, 4),
}), }),
+31 -8
View File
@@ -21,7 +21,7 @@ end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- The panel, w wide: a tree piece (moons.lua puts it under the idle drills). -- The panel, w wide: a tree piece (the aside puts it at the foot of the moon's facts).
function M.box(self, H, c, moon, w) function M.box(self, H, c, moon, w)
local sale = moon.sale local sale = moon.sale
self.sale_open = self.sale_open or {} self.sale_open = self.sale_open or {}
@@ -30,7 +30,12 @@ function M.box(self, H, c, moon, w)
-- Summary: toggle (the row left of the button), offer, delta; Sell moon on the right. -- Summary: toggle (the row left of the button), offer, delta; Sell moon on the right.
local profit = sale.delta > 0 local profit = sale.delta > 0
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta)) .. (profit and " profit" or " vs a new moon") -- (narrow, in the aside: no "· offer", and the delta without its words; its tooltip has them)
local narrow = w < 460
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta))
.. (narrow and "" or (profit and " profit" or " vs a new moon"))
-- Sell moon off: why, on a line under the summary (not only in the button's tooltip).
local why = (not sale.canSell) and (sale.whyText or "") ~= "" and sale.whyText or nil
-- Sell moon asks in place (cards.ask_button): the question where the offer was, Confirm -- Sell moon asks in place (cards.ask_button): the question where the offer was, Confirm
-- left of the button's spot, Cancel on it (the row's toggle is off meanwhile). -- left of the button's spot, Cancel on it (the row's toggle is off meanwhile).
local asking = sale.canSell and cards.asking(self, "sell-moon", sale.confirm, { ["sell-moon"] = moon.id }) local asking = sale.canSell and cards.asking(self, "sell-moon", sale.confirm, { ["sell-moon"] = moon.id })
@@ -60,11 +65,24 @@ function M.box(self, H, c, moon, w)
gui.set_euler(chev, vmath.vector3(0, 0, -90)) gui.set_euler(chev, vmath.vector3(0, 0, -90))
end end
end), end),
row({ height = 48, padding = { 0, 140, 0, 30 }, align = "center" }, { row({ height = 48, padding = { 0, 156, 0, 30 }, align = "center" }, {
fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16), space(8), -- (the label and the offer keep their size: the delta gives way, cut to fit)
fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16), space(8), (function()
inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end), space(10), local l = fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16)
fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 }), l.style.shrink = 0
return l
end)(), space(10),
(not narrow) and fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16) or nil, (not narrow) and space(8) or nil,
(function()
local b = inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end)
b.style.shrink = 0
return b
end)(), space(10),
(function()
local l = fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 })
l.done = function(n) tooltip.add(n, profit and "Profit over what it cost" or "Less than a new moon costs") end
return l
end)(),
}), }),
fg.button(c, "Sell moon", sell_opts, function(b) fg.button(c, "Sell moon", sell_opts, function(b)
if not sale.canSell and (sale.whyText or "") ~= "" then if not sale.canSell and (sale.whyText or "") ~= "" then
@@ -161,7 +179,12 @@ function M.box(self, H, c, moon, w)
}) })
end end
return col({ width = w, padding_bottom = 4 }, { summary, details }, { make = function(p, x, y, bw, bh) local why_line = (why and not asking) and col({ padding = { 0, 14, 10, 30 } }, {
fg.measured(c, function(p, x, y, bw)
return cards.para(p, why, { font = "sans", px = 12, color = "ready" }, x, y, bw)
end),
}) or nil
return col({ width = w, padding_bottom = 4 }, { summary, why_line, details }, { make = function(p, x, y, bw, bh)
local b = ui.rect(p, x, y, bw, bh, "cyan", 0.03) local b = ui.rect(p, x, y, bw, bh, "cyan", 0.03)
ui.outline(b, 0, 0, bw, bh, "edge") ui.outline(b, 0, 0, bw, bh, "edge")
self.fx:box(b) -- (the sale's sweep covers the panel) self.fx:box(b) -- (the sale's sweep covers the panel)
+53 -32
View File
@@ -19,7 +19,6 @@ local fg = require("modules.flex_gui")
local images = require("modules.images") local images = require("modules.images")
local test = require("modules.test") local test = require("modules.test")
local predict = require("modules.predict") local predict = require("modules.predict")
local moon_sale = require("screens.game.moon_sale")
local S = theme.size local S = theme.size
@@ -187,10 +186,10 @@ end
-- the aside shows its facts. -- the aside shows its facts.
-- --
-- Deploying: the tray under the cards lists the idle drills (grouped like copies: same -- Deploying: the tray under the cards lists the idle drills (grouped like copies: same
-- name, track levels, yield, cycle and reach). A well's deploy slot sends the drill picked in the -- name, track levels, yield, cycle and reach). Clicking a tile deploys one of them to the
-- tray (g.deploy_pick), or with none picked the first one-well drill in the tray that -- selected well (none selected: the first well it has room on). A well's deploy slot sends
-- has room there (smallest first; predict.room: what it would get after the moon's drills). Picking is -- the first drill in the tray that has room there, multi-well ones too (smallest first;
-- a toggle; the pick is forgotten once that group is used up. -- predict.room: what it would get after the moon's drills).
local SLOT_H, SLOT_GAP = 60, 6 local SLOT_H, SLOT_GAP = 60, 6
local CARD_MIN, CARD_GAP, CARD_PAD = 210, 12, 12 local CARD_MIN, CARD_GAP, CARD_PAD = 210, 12, 12
@@ -278,11 +277,8 @@ local function deploy_choice(g, st, well, groups)
end end
end end
for _, grp in ipairs(groups) do for _, grp in ipairs(groups) do
-- (a multi-well drill goes out only when picked: it mines other wells too) -- (multi-well drills too: they deploy here like any other, then mine the next wells)
local here, amount = 0, 0 local here, amount = gets_here(st, well, grp.tool)
if (grp.tool.reach or 1) <= 1 then
here, amount = gets_here(st, well, grp.tool)
end
if here > EPS then if here > EPS then
return grp, here, amount, false return grp, here, amount, false
end end
@@ -337,7 +333,7 @@ local function deploy_slot(g, st, well, groups, H)
if ok then if ok then
local params = { tool = grp.tool.id, target = well.id } local params = { tool = grp.tool.id, target = well.id }
H.action(g, b, bn, "stake", params) H.action(g, b, bn, "stake", params)
tooltip.add(b, "Deploy " .. (grp.tool.name or "a drill") .. (explicit and "" or " (the smallest idle one-well drill with room here)")) tooltip.add(b, "Deploy " .. (grp.tool.name or "a drill") .. (explicit and "" or " (the smallest idle drill with room here)"))
end end
end) end)
end end
@@ -456,7 +452,9 @@ local function idle_slot(g, st, well, H)
end end
end end
local who = #names > 0 and (table.concat(names, ", ") .. (n > #names and (" +" .. (n - #names)) or "")) or ui.plural(n, "drill") local who = #names > 0 and (table.concat(names, ", ") .. (n > #names and (" +" .. (n - #names)) or "")) or ui.plural(n, "drill")
local can = st.unlocked.redrill and well.supply > 0 -- (no Redrill while the drills on the well take all it has: the server refuses it)
local full = well.supply > 0 and (well.remain or 1) <= 1e-9
local can = st.unlocked.redrill and well.supply > 0 and not full
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w) return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
local body = ui.rect(p, x, y, w, SLOT_H, "cyan", 0.02) local body = ui.rect(p, x, y, w, SLOT_H, "cyan", 0.02)
ui.outline(body, 0, 0, w, SLOT_H, "line_hi") ui.outline(body, 0, 0, w, SLOT_H, "line_hi")
@@ -469,7 +467,8 @@ local function idle_slot(g, st, well, H)
H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id }) H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
tooltip.add(b, "Send the drills that last mined this well out again") tooltip.add(b, "Send the drills that last mined this well out again")
end, { height = SLOT_BTN_H }) or row({ height = SLOT_BTN_H, align = "center" }, { end, { height = SLOT_BTN_H }) or row({ height = SLOT_BTN_H, align = "center" }, {
fit(body, well.supply > 0 and "Last mined here" or "Last mined here · the well is empty", S11), fit(body, well.supply <= 0 and "Last mined here · the well is empty"
or (full and "Last mined here · the drills on it take the rest" or "Last mined here"), S11),
}), }),
}) })
fg.build(flex.solve(tree, 0, 0), body) fg.build(flex.solve(tree, 0, 0), body)
@@ -510,10 +509,13 @@ local function well_card(g, c, st, moon, well, max, groups, H, enters)
slots[#slots + 1] = deploy_slot(g, st, well, groups, H) slots[#slots + 1] = deploy_slot(g, st, well, groups, H)
end end
local n, next_d = 0, next_drill(g, well.drills) local n, next_d = 0, next_drill(g, well.drills)
-- Tapped out once the ready drills are claimed (nothing left, or what is left is the
-- other drills' on it, the server's `remain`): Claim only, no Redeploy.
local tapped = well.supply - ready.amount(g, well, well.cap) <= 1e-9 or (well.remain or 1) <= 1e-9
for _, d in ipairs(well.drills or {}) do for _, d in ipairs(well.drills or {}) do
local due = d.ready or (d["end"] or math.huge) <= H.now(g) local due = d.ready or (d["end"] or math.huge) <= H.now(g)
n = n + (due and 1 or 0) n = n + (due and 1 or 0)
slots[#slots + 1] = drill_slot(g, st, d, scope, not dry and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters) slots[#slots + 1] = drill_slot(g, st, d, scope, not dry and not tapped and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters)
end end
if (well.redrill or 0) > 0 then if (well.redrill or 0) > 0 then
slots[#slots + 1] = idle_slot(g, st, well, H) slots[#slots + 1] = idle_slot(g, st, well, H)
@@ -556,10 +558,16 @@ local function wide_card(g, c, st, moon, inner, H, enters)
local wide = moon.wide local wide = moon.wide
local scope = { ["claim-wide"] = moon.id } local scope = { ["claim-wide"] = moon.id }
local slots, n, next_d = {}, 0, next_drill(g, wide.drills) local slots, n, next_d = {}, 0, next_drill(g, wide.drills)
-- (the moon tapped out once these are claimed: no Redeploy)
local left = 0
for _, w in ipairs(moon.wells or {}) do
left = left + w.supply
end
local tapped = left - ready.amount(g, wide, wide.cap) <= 1e-9
for _, d in ipairs(wide.drills or {}) do for _, d in ipairs(wide.drills or {}) do
local due = d.ready or (d["end"] or math.huge) <= H.now(g) local due = d.ready or (d["end"] or math.huge) <= H.now(g)
n = n + (due and 1 or 0) n = n + (due and 1 or 0)
slots[#slots + 1] = drill_slot(g, st, d, scope, st.unlocked.redeploy, due and n or 0, d == next_d, H, enters) slots[#slots + 1] = drill_slot(g, st, d, scope, not tapped and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters)
end end
local sw = inner - CARD_PAD * 2 local sw = inner - CARD_PAD * 2
return col({ width = inner, padding = CARD_PAD }, { return col({ width = inner, padding = CARD_PAD }, {
@@ -581,7 +589,7 @@ end
local function tray(g, c, st, groups, inner, H) local function tray(g, c, st, groups, inner, H)
local head = row({ height = 16, align = "center" }, { local head = row({ height = 16, align = "center" }, {
fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0), fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0),
#groups > 0 and fit(c, g.deploy_pick and "Picked: + Deploy on a well sends it" or "Pick one, then + Deploy on a well", "small") or nil, #groups > 0 and fit(c, "Click one to deploy it to the selected well", "small") or nil,
}) })
if #groups == 0 then if #groups == 0 then
return col({ width = inner }, { return col({ width = inner }, {
@@ -595,12 +603,30 @@ local function tray(g, c, st, groups, inner, H)
}), }),
}) })
end end
-- The well a tile deploys to: the selected one, else the first it has room on.
local moon = st.moon
local function target(t)
local wells = moon and moon.wells or {}
for _, w in ipairs(wells) do
if w.id == g.view.well then
return w
end
end
for _, w in ipairs(wells) do
if w.supply > 0 and gets_here(st, w, t) > EPS then
return w
end
end
end
local tiles = {} local tiles = {}
for _, grp in ipairs(groups) do for _, grp in ipairs(groups) do
local t = grp.tool local t = grp.tool
local picked = grp.key == g.deploy_pick local well = target(t)
local here = well and gets_here(st, well, t) or 0
local ok = well ~= nil and well.supply > 0 and here > EPS
local picked = false
tiles[#tiles + 1] = made({ height = TILE_H }, function(p, x, y, w) tiles[#tiles + 1] = made({ height = TILE_H }, function(p, x, y, w)
local b, bn = ui.button(p, "", x, y, { variant = "ghost", on = picked, w = w, h = TILE_H }) local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = TILE_H, disabled = not ok })
local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg") local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg")
if t.image then if t.image then
images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), t.image) images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), t.image)
@@ -617,11 +643,12 @@ local function tray(g, c, st, groups, inner, H)
if picked then if picked then
ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9) ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9)
end end
tooltip.add(b, (t.name or "Drill") .. (picked and " · picked (click to unpick)" or " · pick it for + Deploy")) if ok then
button.bind(g, b, bn, { id = "pick:" .. grp.key, label = M.short_name(t), on_click = function() tooltip.add(b, "Deploy " .. (t.name or "a drill") .. " to the " .. well.resource .. " well")
g.deploy_pick = (not picked) and grp.key or nil H.action(g, b, bn, "stake", { tool = t.id, target = well.id })
g.dirty = true else
end }) tooltip.add(b, (t.name or "Drill") .. (well and (" · no room on the " .. well.resource .. " well") or " · no room on this moon"))
end
end) end)
end end
return col({ width = inner }, { head, space(0, 8), flex.grid(inner, TILE_MIN, 8, tiles) }) return col({ width = inner }, { head, space(0, 8), flex.grid(inner, TILE_MIN, 8, tiles) })
@@ -728,14 +755,7 @@ local function moon_view(g, c, px, py, inner, min_h, H)
end end
kids[#kids + 1] = space(0, 20) kids[#kids + 1] = space(0, 20)
kids[#kids + 1] = tray(g, c, st, groups, inner, H) kids[#kids + 1] = tray(g, c, st, groups, inner, H)
-- (selling the moon, a destructive action, is in the aside: aside.lua)
-- Selling the moon (once the store is open), at the foot.
if st.unlocked.store and moon.sale then
kids[#kids + 1] = space(0, 22)
kids[#kids + 1] = made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end)
kids[#kids + 1] = space(0, 12)
kids[#kids + 1] = moon_sale.box(g, H, c, moon, inner)
end
local page = col({ width = inner, min_height = min_h }, kids) local page = col({ width = inner, min_height = min_h }, kids)
fg.build(flex.solve(page, px, py), c) fg.build(flex.solve(page, px, py), c)
@@ -749,7 +769,8 @@ end
-- Section contract (AGENTS.md, "Game screen"): root is the main panel, (px, py) its -- Section contract (AGENTS.md, "Game screen"): root is the main panel, (px, py) its
-- padding, w x h its size. -- padding, w x h its size.
function M.build(g, root, px, py, w, h, H) function M.build(g, root, px, py, w, h, H)
local inner = math.min(w - px * 2, S.content_max) -- (as wide as a card grid gets: a moon's well cards, 5 across where there is room)
local inner = math.min(w - px * 2, S.cards_max)
px = px + math.floor((w - px * 2 - inner) / 2) -- capped content is centered px = px + math.floor((w - px * 2 - inner) / 2) -- capped content is centered
local st = g.state local st = g.state
M.track_drills(g) M.track_drills(g)
+37 -33
View File
@@ -488,9 +488,9 @@ end
-- "Owned N" on the recipe cards: fx counters ("refined:<item>", bound after the build), so -- "Owned N" on the recipe cards: fx counters ("refined:<item>", bound after the build), so
-- a collect's motes land there and the number counts up. Laid out at the final amount. -- a collect's motes land there and the number counts up. Laid out at the final amount.
local counted = {} local counted = {}
local function own_label(c, r) local function own_label(c, r, color)
local l = fg.label(c, ui.money(r.owned), CAP_NUM, 24) local l = fg.label(c, ui.money(r.owned or 0), { font = "mono_semi", px = 24, color = color }, 26)
counted[#counted + 1] = { key = "refined:" .. r.item, node = l.node, amount = r.owned } counted[#counted + 1] = { key = "refined:" .. r.item, node = l.node, amount = r.owned or 0 }
return l return l
end end
@@ -501,8 +501,9 @@ local function recipe_card(self, H, c, st, rf, r)
local why = (r.whyText or "") ~= "" and r.whyText or WHY[r.why] or r.why local why = (r.whyText or "") ~= "" and r.whyText or WHY[r.why] or r.why
local owned = (r.owned or 0) > 0 local owned = (r.owned or 0) > 0
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N · -- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N" over
-- owned X" over the name. -- the name, and on the right what you own, big, in the item's color (a collect's motes
-- fly into it and it counts up), shown at 0 too.
local glyph = { style = { width = 36, height = 36, shrink = 0 }, make = function(p, x, y) local glyph = { style = { width = 36, height = 36, shrink = 0 }, make = function(p, x, y)
local g = ui.rect(p, x, y, 36, 36, color, 0.85) local g = ui.rect(p, x, y, 36, 36, color, 0.85)
gui.play_flipbook(g, "pentagon") gui.play_flipbook(g, "pentagon")
@@ -510,13 +511,14 @@ local function recipe_card(self, H, c, st, rf, r)
end } end }
local head = row({ height = 43.3, gap = 12, align = "start" }, { local head = row({ height = 43.3, gap = 12, align = "start" }, {
col({ padding_top = 3.6 }, { glyph }), col({ padding_top = 3.6 }, { glyph }),
col({ grow = 1, align = "start" }, { col({ grow = 1, shrink = 1, align = "start" }, {
row({}, { fg.label(c, "Tier " .. r.tier, "cap", 24),
fg.label(c, "Tier " .. r.tier .. (owned and " · owned " or ""), "cap", 24),
owned and own_label(c, r) or nil,
}),
fg.label(c, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.14, upper = true }, 17.8, { fit = true }), fg.label(c, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.14, upper = true }, 17.8, { fit = true }),
}), }),
col({ align = "end", shrink = 0 }, {
fg.label(c, "Owned", "cap", 14),
own_label(c, r, owned and color or "muted"),
}),
}) })
-- Inputs per unit: need and name, what you hold on the right (red when short). -- Inputs per unit: need and name, what you hold on the right (red when short).
@@ -558,32 +560,34 @@ local function recipe_card(self, H, c, st, rf, r)
return n return n
end }) end })
-- The form: quantity and Max on a row, Refine the card's width under them (beside the -- The form: the quantity and Refine (that many) on a row, Max (as many as you can, the
-- quantity and "Max 250" it was cramped). -- usual click) the card's width under them, primary.
local qty, input = qty_field(self, r, off, rf.maxBatch) local qty, input = qty_field(self, r, off, rf.maxBatch)
qty.style.width, qty.style.grow = nil, 1 qty.style.width, qty.style.grow = nil, 1
local refine local refine
local refine_btn = btn("Refine", BTN_SM, { primary = true, disabled = off }, function(b, nodes) local refine_btn = btn("Refine", BTN_SM, { disabled = off }, function(b, nodes)
if off then if off then
return tooltip.add(b, why) return tooltip.add(b, why)
end end
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)
-- Enter in the quantity refines (the web's form). tooltip.add(b, "Refine the number in the box")
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } }) -- Enter in the quantity refines (the web's form).
end, { grow = 1 }) blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
local form = col({ shrink = 0 }, { row({ height = BTN_SM, gap = 6, shrink = 0 }, { end)
qty, local max_btn = btn(off and "Max" or ("Max · " .. ui.money(r.maxQty)), BTN_SM, { primary = true, disabled = off }, function(b, nodes)
btn(off and "Max" or ("Max " .. r.maxQty), BTN_SM, { disabled = off }, function(b, nodes) if off then
if off then return tooltip.add(b, why)
return tooltip.add(b, why) end
end H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" }) tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")") end, { grow = 1 })
end), local form = col({ shrink = 0 }, {
}), space(0, 6), row({ height = BTN_SM, shrink = 0 }, { refine_btn }) }) row({ height = BTN_SM, gap = 6, shrink = 0 }, { qty, refine_btn }),
space(0, 6), row({ height = BTN_SM, shrink = 0 }, { max_btn }),
})
local desc = catalog_desc(st, r.item) local desc = catalog_desc(st, r.item)
return col({ padding = 17 }, { return col({ padding = 17 }, {
+1 -1
View File
@@ -1,4 +1,4 @@
"""Card sections (needs --fixture): a click on a tool card opens its drill panel, a click """Card sections (needs --fixture): a click on a tool row (Equipment) opens its drill panel, a click
on an advisor card shows it in the aside; hover lights a card's edge; seat tiles and the on an advisor card shows it in the aside; hover lights a card's edge; seat tiles and the
close are focusable. Shots of each.""" close are focusable. Shots of each."""
+1 -1
View File
@@ -1,6 +1,6 @@
"""The drill panel during onboarding (needs --fixture-file of a plain player: """The drill panel during onboarding (needs --fixture-file of a plain player:
scripts/fixture.py <api> --plain, and an MM_DEV=1 server): once the first deploy reveals scripts/fixture.py <api> --plain, and an MM_DEV=1 server): once the first deploy reveals
Equipment, a tool card opens its panel (stats, the tracks' ladders) without the Store's Equipment, a tool row opens its panel (stats, the tracks' ladders) without the Store's
parts (the upgrade buttons, Sell drill); after the first claim and sale reveal the Store, parts (the upgrade buttons, Sell drill); after the first claim and sale reveal the Store,
the same panel has them. Shots "before" and "after", each linted.""" the same panel has them. Shots "before" and "after", each linted."""
import os import os
+73
View File
@@ -0,0 +1,73 @@
"""The Equipment table (needs --fixture): a row per tool, copies too (the fixture's two
PD-1s), one mining, one ready; the idle PD-1's cycle track taken to its top (Max in place
of its button) and the credits cut to a few hundred (some upgrades short). Shots of the
table, a row's drill panel, a row's Sell asking in place (Cancel on Sell's spot), and two
clicks on an affordable upgrade button (click_check: it stays put). Each linted. Args: an
optional name prefix."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from lists import admin, call # noqa: E402
from sellask import ask, covers, rect_of # noqa: E402
def run(game, args):
fx = game.fixture
pre = (args[0] + "-") if args else ""
idle = fx["tool_idle"]
game.idle()
for _ in range(12):
r = call(game, "POST", "/api/act/upgrade", {"tool": idle, "track": "cycle"})
if not r.get("ok"):
break
st = call(game, "GET", "/api/state?s=equipment")["state"]
tr = [t for t in st["equipment"]["tools"] if t["id"] == idle][0]["tracks"][1]
game.expect(tr["next"] is None, "cycle track at its top: %s/%s" % (tr["level"], tr["max"]))
# One mining for sure (the fixture's stakes can find their well empty): an idle tool
# other than the PD-1s on the second moon.
ms = call(game, "GET", "/api/state?s=moons&moon=" + fx["moon2"])["state"]
wells = [w for w in ms["moon"]["wells"] if w["supply"] > 0]
spare = [t for t in st["equipment"]["tools"] if not t["stake"] and t["storeItemId"] != 1]
if wells and spare:
r = call(game, "POST", "/api/act/stake", {"tool": spare[0]["id"], "target": wells[0]["id"]})
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
admin(game, "credits", amount=max(1, st["credits"] - 260), op="remove")
game.navigate("{section='equipment'}")
game.idle()
game.sleep(1)
game.shot(pre + "eqrows-table", settle=1.0)
game.lint(pre + "eqrows-table")
# A row opens its drill panel.
game.scroll_to("drill:" + idle)
game.idle()
game.click("drill:" + idle)
game.wait_for("view.drill == '%s' and not g.busy" % idle)
game.idle()
game.shot(pre + "eqrows-panel", settle=1.0)
game.lint(pre + "eqrows-panel")
# A row's Sell asks in place.
sell = "confirm:sell-tool:" + idle
game.scroll_to(sell)
game.idle()
before = rect_of(game, sell)
ask(game, sell)
game.expect(not game.eval("g.dialog:is_open()"), "no dialog opens")
game.expect(covers(rect_of(game, sell + ":cancel"), before), "Cancel takes Sell's spot")
game.shot(pre + "eqrows-asking", settle=0.3)
game.lint(pre + "eqrows-asking")
game.key("key_esc")
game.wait_for("g.ask == nil", 5)
game.idle()
# Two clicks on an affordable upgrade keep hitting the same row's button.
ups = [e for e in game.elements() if e["id"].startswith("act:upgrade?") and e.get("enabled", True)]
game.expect(ups, "an affordable upgrade")
if ups:
id_ = ups[0]["id"]
for n in range(2):
if game.eval("el(%r)" % id_):
game.click_check(id_, name=pre + "eqrows-click-%d" % n)
game.shot(pre + "eqrows-after", settle=1.0)
+2 -2
View File
@@ -1,5 +1,5 @@
"""Drill upgrade tracks (needs --fixture): the Equipment cards' two track boxes (yield and """Drill upgrade tracks (needs --fixture): the Equipment table's two track columns (yield and
cycle, side by side: level, value ▸ next, Upgrade or Max level, the kept need line) and the cycle: value ▸ next, level pips, Upgrade or Max, the kept need line) and the
drill panel of the fixture's upgraded idle PD-1 (both boxes, both ladders, Sell drill). drill panel of the fixture's upgraded idle PD-1 (both boxes, both ladders, Sell drill).
Clicking each Upgrade twice in the panel keeps the buttons where they are (click_check). Clicking each Upgrade twice in the panel keeps the buttons where they are (click_check).
Shots: tracks-equipment, tracks-panel, tracks-panel-after. Args: an optional name prefix.""" Shots: tracks-equipment, tracks-panel, tracks-panel-after. Args: an optional name prefix."""
+28 -20
View File
@@ -1,11 +1,12 @@
"""Well cards (needs --fixture and an MM_DEV=1 server: admin finish-stakes): every action """Well cards (needs --fixture and an MM_DEV=1 server: admin finish-stakes): every action
on a well is on its card in the main panel, the aside only shows. Buys a fresh moon, then: on a well is on its card in the main panel, the aside only shows. Buys a fresh moon, then:
the cards with their empty "+ Deploy" slots and the tray of idle drills (shot cards-empty); the cards with their empty "+ Deploy" slots and the tray of idle drills (shot cards-empty);
picks a drill in the tray (cards-picked); deploys it twice from the same slot (the slot a tray tile deploys straight to the selected well (cards-picked); the card's deploy slot
keeps its spot: click_check); a multi-well drill (the wide card); finishes the drills: the keeps its spot (click_check); a multi-well drill (the wide card); finishes the drills: the
ready slots, gold with Claim and Redeploy (cards-ready); Claim on a slot (click_check), the ready slots, gold with Claim and Redeploy (cards-ready); Claim on a slot (click_check), the
idle slot with Redrill (cards-idle), Redrill; selling the moon is in the main panel. The idle slot with Redrill (cards-idle), Redrill; selling the moon is in the aside. The
aside, with a well and with none selected, has no buttons (aside-well, aside-moon). aside, with a well and with none selected, has no buttons but Sell moon, the one destructive
action kept there (aside-well, aside-moon).
Args: an optional name prefix.""" Args: an optional name prefix."""
import os import os
import sys import sys
@@ -26,7 +27,7 @@ def aside_buttons(game):
r = e.get("rect") r = e.get("rect")
if not r or not e.get("visible") or e.get("kind") == "wait": if not r or not e.get("visible") or e.get("kind") == "wait":
continue continue
if r["x"] >= left - 1 and r["y"] <= top + 1: if r["x"] >= left - 1 and r["y"] <= top + 1 and not e["id"].startswith(("sell-moon", "moon-sale:")):
out.append(e["id"]) out.append(e["id"])
return out return out
@@ -52,21 +53,26 @@ def run(game, args):
game.shot(pre + "cards-empty", settle=0.8) game.shot(pre + "cards-empty", settle=0.8)
game.lint(pre + "cards-empty") game.lint(pre + "cards-empty")
# Pick the last tile in the tray (one-well), deploy it from the first well's slot. # A tray tile deploys straight to the selected well (no picking): select the first well,
tiles = ids(game, "pick:") # click the lowest "stake" button there (the tray sits under the cards).
game.expect(len(tiles) > 0, "the tray has idle drills") game.navigate("{section='moons', moon='%s', well='%s'}" % (moon["id"], wells[0]))
one = [t for t in tiles if t.endswith(":1")] # (reach 1; keys end with yield:cycle:reach)
pick = min(one or tiles, key=lambda t: float(t.split(":")[-3])) # (the smallest: room for two)
game.scroll_to(pick)
game.click(pick)
game.idle() game.idle()
game.expect(game.eval("g.deploy_pick ~= nil"), "a tile picks")
game.shot(pre + "cards-picked", settle=0.5)
slot = "act:stake?target=" + wells[0] slot = "act:stake?target=" + wells[0]
game.scroll_to(ids(game, slot)[0]) btns = [e for e in game.elements() if e["id"].startswith(slot) and e.get("visible", True)]
game.click_check(ids(game, slot)[0], name=pre + "click-deploy") game.expect(len(btns) > 1, "the tray's tiles deploy to the selected well")
if ids(game, slot): n0 = game.eval("#st.stakes")
game.click_check(ids(game, slot)[0], name=pre + "click-deploy-2") tile = max(btns, key=lambda e: e["rect"]["y"])["id"]
game.scroll_to(tile)
game.click(tile)
game.wait_for("#st.stakes > %d" % n0, 10)
game.idle()
game.shot(pre + "cards-picked", settle=0.5)
# The card's deploy slot (topmost) keeps its spot under repeated clicks.
top = min((e for e in game.elements() if e["id"].startswith(slot) and e.get("visible", True)),
key=lambda e: e["rect"]["y"], default=None)
if top:
game.scroll_to(top["id"])
game.click_check(top["id"], name=pre + "click-deploy")
game.idle() game.idle()
# A multi-well drill (bought when none is idle). # A multi-well drill (bought when none is idle).
@@ -109,12 +115,14 @@ def run(game, args):
game.shot(pre + "cards-idle", settle=0.8) game.shot(pre + "cards-idle", settle=0.8)
redrill = ids(game, "act:redrill?redrill-well=" + wells[0]) redrill = ids(game, "act:redrill?redrill-well=" + wells[0])
left_here = game.eval("(function() for _, w in ipairs(st.moon.wells) do if w.id == '%s' then return w.supply end end end)()" % wells[0]) left_here = game.eval("(function() for _, w in ipairs(st.moon.wells) do if w.id == '%s' then return w.supply end end end)()" % wells[0])
game.expect(len(redrill) > 0 or not left_here, "the idle slot has Redrill (the well has %s left)" % left_here) remain = game.eval("(function() for _, w in ipairs(st.moon.wells) do if w.id == '%s' then return w.remain end end end)()" % wells[0])
# (none while the drills on it take all it has: the server would refuse it)
game.expect(len(redrill) > 0 or not left_here or not remain, "the idle slot has Redrill (the well has %s left, %s free)" % (left_here, remain))
if redrill: if redrill:
game.click(redrill[0]) game.click(redrill[0])
game.wait_for("not g.busy and #g.pending == 0", 20) game.wait_for("not g.busy and #g.pending == 0", 20)
game.idle() game.idle()
game.expect(ids(game, "moon-sale:" + moon["id"]) or ids(game, "sell-moon"), "selling the moon is in the main panel") game.expect(ids(game, "moon-sale:" + moon["id"]) or ids(game, "sell-moon"), "selling the moon is in the aside")
game.shot(pre + "cards-redrilled", settle=0.8) game.shot(pre + "cards-redrilled", settle=0.8)
left = aside_buttons(game) left = aside_buttons(game)
game.expect(not left, "the aside (the moon) has no buttons: %s" % left) game.expect(not left, "the aside (the moon) has no buttons: %s" % left)
+17
View File
@@ -1,3 +1,20 @@
# Wider steps (2026-10-22)
Playing the progression pass, the author had a LunarLite L100 (and money for more) about
33 minutes in, first C moon at 19 minutes: too fast. Their run's money: mostly raw sales
from five C moons, plus one Plasma Weave sale (₥32k, of which the refining margin was only
about ₥9k). The fix is a steeper grade ladder: each grade's price and wells, and its
drill's yield, price and upgrade prices, scaled by the same factor (C ×2, B ×3, A ×5,
S ×8), which keeps a moon's value (1.3–1.8× its price) and a drill's 4–5 claims per well on
its grade, while the jump to the next grade grows faster than income. D and the PD-1 are
unchanged. Refined items now gain about 20% over their inputs (was 40–100%).
Grades: D 300, C 12k, B 90k, A 1.5M, S 24M. Drills: PD-1 1,200 (8), PX-4 8,000 (120), L100
40,200 (900), L300 645k (20,000), LRE 9.44M (384k), EDE 17M (880k), LCE 24.8M (1.6M).
Sim, first moon of each grade: copies C 12m, B 30m, A 58m, S 2h33; boosts C 10m, B 21m,
A 40m, S 1h49 (was C 7m, B 15m, A 32m, S 1h04). A real player ran about 2.5× slower than
the sim's (C at 19m against the sim's 7m), so expect roughly C 30m, B 1h15 in play.
# Progression: each new drill rips through the current grade (2026-10-22) # Progression: each new drill rips through the current grade (2026-10-22)
The loop: on a grade, its drill needs about 3–6 claims per well; the next drill (bought The loop: on a grade, its drill needs about 3–6 claims per well; the next drill (bought
+6 -6
View File
@@ -85,8 +85,8 @@ The other sections' actions:
| action | params | notes | | action | params | notes |
|---|---|---| |---|---|---|
| `buy-tool` | `id` (store item id) | response has `buyModal` | | `buy-tool` | `id` (store item id) | response has `buyModal` |
| `upgrade` | `tool` (tool id), `track` (`yield` or `cycle`) | one level on that track; `drill=` follows the copy upgraded | | `upgrade` | `tool` (tool id), `track` (`yield` or `cycle`) | one level on that track of that tool (also while it mines: from its next drill) |
| `sell-tool` | `tool` | idle, nothing to claim, not the last tool (`noSale`) | | `sell-tool` | `tool` | that tool: idle, nothing to claim, not the last tool (`noSale`) |
| `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) | | `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
| `collect` | `job` (job id), or none for every finished batch | | | `collect` | `job` (job id), or none for every finished batch | |
| `buy-slot` | – | refinery slot | | `buy-slot` | – | refinery slot |
@@ -167,8 +167,8 @@ short code, listed with each field, and the web's text next to it.
drill} drill}
``` ```
- Copies of a tool (same type and track levels) are one row: `count` copies, `idle` of them - Every tool is its own row, copies too: `count` is 1, `idle` 1 when it isn't mining, `stake`
idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill. its drill.
- `tracks`: the tool's two upgrade tracks, yield then cycle: `{kind, name, level, max, value, - `tracks`: the tool's two upgrade tracks, yield then cycle: `{kind, name, level, max, value,
next, ladder}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds next, ladder}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds
per cycle); values are a stake's now, with the player's bonuses and live events. Upgrade per cycle); values are a stake's now, with the player's bonuses and live events. Upgrade
@@ -352,8 +352,8 @@ The client may show results before the server answers; the next state replaces t
recompute: the moon's stakes, oldest `start` first (ties in `stakes` order), are claimed recompute: the moon's stakes, oldest `start` first (ties in `stakes` order), are claimed
in turn, each from what the earlier ones left (`remain`, starting at `supply`). A one-well in turn, each from what the earlier ones left (`remain`, starting at `supply`). A one-well
stake takes `min(amount, remain)` of its well; a multi-well stake (`reach` > 1) covers its stake takes `min(amount, remain)` of its well; a multi-well stake (`reach` > 1) covers its
own well (if not empty) then the others with something left, most first (ties in own well (if not empty) then the next wells in `moon.wells` order (wrapping round), each
`moon.wells` order), up to `reach` wells, taking from each in turn as much as is left if it has something left, up to `reach` wells, taking from each in turn as much as is left
until it has `amount`. `demand` is the one-well amounts on the well plus what each until it has `amount`. `demand` is the one-well amounts on the well plus what each
multi-well stake would take from it out of `supply`; `dryAfter` the claim (among those multi-well stake would take from it out of `supply`; `dryAfter` the claim (among those
taking from the well) that leaves it at 0. A deploy card's `here` is what a new stake taking from the well) that leaves it at 0. A deploy card's `here` is what a new stake
+89 -89
View File
@@ -5,13 +5,13 @@
"moonGrades": [ "moonGrades": [
{"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 23, "wellSupplyMax": 46, "wellCountWeights": {"1": 40, "2": 60}, {"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 23, "wellSupplyMax": 46, "wellCountWeights": {"1": 40, "2": 60},
"affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}}, "affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}},
{"grade": "C", "price": 6000, "minValue": 1.3, "wellSupplyMin": 216, "wellSupplyMax": 430, "wellCountWeights": {"2": 50, "3": 50}, {"grade": "C", "price": 12000, "minValue": 1.3, "wellSupplyMin": 432, "wellSupplyMax": 860, "wellCountWeights": {"2": 50, "3": 50},
"affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}}, "affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}},
{"grade": "B", "price": 30000, "wellSupplyMin": 738, "wellSupplyMax": 1968, "wellCountWeights": {"2": 30, "3": 40, "4": 30}, {"grade": "B", "price": 90000, "wellSupplyMin": 2214, "wellSupplyMax": 5904, "wellCountWeights": {"2": 30, "3": 40, "4": 30},
"affinities": {"Sollux": 1, "Shadowstone": 1.5, "Azurium": 2, "Novafor": 2, "Nebulance": 1.2}}, "affinities": {"Sollux": 1, "Shadowstone": 1.5, "Azurium": 2, "Novafor": 2, "Nebulance": 1.2}},
{"grade": "A", "price": 300000, "wellSupplyMin": 4920, "wellSupplyMax": 12915, "wellCountWeights": {"3": 30, "4": 40, "5": 30}, {"grade": "A", "price": 1500000, "wellSupplyMin": 24600, "wellSupplyMax": 64575, "wellCountWeights": {"3": 30, "4": 40, "5": 30},
"affinities": {"Sollux": 0.6, "Shadowstone": 1, "Azurium": 1.5, "Novafor": 2, "Nebulance": 2}}, "affinities": {"Sollux": 0.6, "Shadowstone": 1, "Azurium": 1.5, "Novafor": 2, "Nebulance": 2}},
{"grade": "S", "price": 3000000, "wellSupplyMin": 42230, "wellSupplyMax": 103730, "wellCountWeights": {"4": 50, "5": 50}, {"grade": "S", "price": 24000000, "wellSupplyMin": 337840, "wellSupplyMax": 829840, "wellCountWeights": {"4": 50, "5": 50},
"affinities": {"Sollux": 0.4, "Shadowstone": 0.6, "Azurium": 1, "Novafor": 2, "Nebulance": 3.5}} "affinities": {"Sollux": 0.4, "Shadowstone": 0.6, "Azurium": 1, "Novafor": 2, "Nebulance": 3.5}}
], ],
"moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600}, "moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600},
@@ -55,23 +55,23 @@
"name": "Percussion Drill PX-4", "name": "Percussion Drill PX-4",
"description": "Hammers the crust a thousand times a second to crack open ore seams.", "description": "Hammers the crust a thousand times a second to crack open ore seams.",
"image": "pickaxe.png", "image": "pickaxe.png",
"price": 4000, "price": 8000,
"claimAmount": 60, "claimAmount": 120,
"completionTimeInSecs": 6, "completionTimeInSecs": 6,
"tracks": { "tracks": {
"yield": {"name": "Wider chisel", "levels": [ "yield": {"name": "Wider chisel", "levels": [
{"value": 72, "price": 760}, {"value": 144, "price": 1520},
{"value": 84, "price": 760}, {"value": 168, "price": 1520},
{"value": 96, "price": 760, "parts": {"Dusk Alloy": 2}}, {"value": 192, "price": 1520, "parts": {"Dusk Alloy": 2}},
{"value": 108, "price": 760, "parts": {"Cryo Cell": 1}}, {"value": 216, "price": 1520, "parts": {"Cryo Cell": 1}},
{"value": 120, "price": 760, "parts": {"Plasma Weave": 1}} {"value": 240, "price": 1520, "parts": {"Plasma Weave": 1}}
]}, ]},
"cycle": {"name": "Pneumatic hammer", "levels": [ "cycle": {"name": "Pneumatic hammer", "levels": [
{"value": 5.2, "price": 580}, {"value": 5.2, "price": 1160},
{"value": 4.5, "price": 680}, {"value": 4.5, "price": 1360},
{"value": 3.9, "price": 780, "parts": {"Dusk Alloy": 2}}, {"value": 3.9, "price": 1560, "parts": {"Dusk Alloy": 2}},
{"value": 3.4, "price": 860, "parts": {"Cryo Cell": 1}}, {"value": 3.4, "price": 1720, "parts": {"Cryo Cell": 1}},
{"value": 3, "price": 890, "parts": {"Plasma Weave": 1}} {"value": 3, "price": 1780, "parts": {"Plasma Weave": 1}}
]} ]}
} }
}, },
@@ -80,23 +80,23 @@
"name": "LunarLite L100", "name": "LunarLite L100",
"description": "An entry-level rotary drill: point it down and the ore rolls in.", "description": "An entry-level rotary drill: point it down and the ore rolls in.",
"image": "lunarlite.jpg", "image": "lunarlite.jpg",
"price": 13400, "price": 40200,
"claimAmount": 300, "claimAmount": 900,
"completionTimeInSecs": 8, "completionTimeInSecs": 8,
"tracks": { "tracks": {
"yield": {"name": "Wide-bore bit", "levels": [ "yield": {"name": "Wide-bore bit", "levels": [
{"value": 355, "price": 2300}, {"value": 1060, "price": 6900},
{"value": 423, "price": 2900}, {"value": 1270, "price": 8700},
{"value": 477, "price": 2300, "parts": {"Cryo Cell": 1}}, {"value": 1430, "price": 6900, "parts": {"Cryo Cell": 1}},
{"value": 545, "price": 2900, "parts": {"Plasma Weave": 2}}, {"value": 1640, "price": 8700, "parts": {"Plasma Weave": 2}},
{"value": 600, "price": 2300, "parts": {"Plasma Weave": 3}} {"value": 1800, "price": 6900, "parts": {"Plasma Weave": 3}}
]}, ]},
"cycle": {"name": "Spindle motor", "levels": [ "cycle": {"name": "Spindle motor", "levels": [
{"value": 7, "price": 1800}, {"value": 7, "price": 5400},
{"value": 6, "price": 2400}, {"value": 6, "price": 7200},
{"value": 5.2, "price": 2600, "parts": {"Cryo Cell": 1}}, {"value": 5.2, "price": 7800, "parts": {"Cryo Cell": 1}},
{"value": 4.6, "price": 2600, "parts": {"Plasma Weave": 2}}, {"value": 4.6, "price": 7800, "parts": {"Plasma Weave": 2}},
{"value": 4, "price": 3300, "parts": {"Plasma Weave": 3}} {"value": 4, "price": 9900, "parts": {"Plasma Weave": 3}}
]} ]}
} }
}, },
@@ -105,24 +105,24 @@
"name": "LunarLite L300", "name": "LunarLite L300",
"description": "The L100's laser-cutting big brother, built to burn through bedrock.", "description": "The L100's laser-cutting big brother, built to burn through bedrock.",
"image": "lunarlite300.png", "image": "lunarlite300.png",
"price": 129000, "price": 645000,
"claimAmount": 4000, "claimAmount": 20000,
"completionTimeInSecs": 10, "completionTimeInSecs": 10,
"reach": 2, "reach": 2,
"tracks": { "tracks": {
"yield": {"name": "Wider beam", "levels": [ "yield": {"name": "Wider beam", "levels": [
{"value": 4740, "price": 23000}, {"value": 23700, "price": 115000},
{"value": 5630, "price": 27000}, {"value": 28200, "price": 135000},
{"value": 6370, "price": 23000, "parts": {"Cryo Cell": 2}}, {"value": 31800, "price": 115000, "parts": {"Cryo Cell": 2}},
{"value": 7260, "price": 27000, "parts": {"Plasma Weave": 3}}, {"value": 36300, "price": 135000, "parts": {"Plasma Weave": 3}},
{"value": 8000, "price": 23000, "parts": {"Void Core": 1}} {"value": 40000, "price": 115000, "parts": {"Void Core": 1}}
]}, ]},
"cycle": {"name": "Laser capacitors", "levels": [ "cycle": {"name": "Laser capacitors", "levels": [
{"value": 8.7, "price": 18000}, {"value": 8.7, "price": 90000},
{"value": 7.5, "price": 23000}, {"value": 7.5, "price": 115000},
{"value": 6.5, "price": 25000, "parts": {"Cryo Cell": 2}}, {"value": 6.5, "price": 125000, "parts": {"Cryo Cell": 2}},
{"value": 5.7, "price": 26000, "parts": {"Plasma Weave": 3}}, {"value": 5.7, "price": 130000, "parts": {"Plasma Weave": 3}},
{"value": 5, "price": 30000, "parts": {"Void Core": 1}} {"value": 5, "price": 150000, "parts": {"Void Core": 1}}
]} ]}
} }
}, },
@@ -131,24 +131,24 @@
"name": "Lunar Regolith Extractor LRE-3000", "name": "Lunar Regolith Extractor LRE-3000",
"description": "An auger rig that chews through regolith by the ton and keeps every grain worth selling.", "description": "An auger rig that chews through regolith by the ton and keeps every grain worth selling.",
"image": "regolith.jpg", "image": "regolith.jpg",
"price": 1180000, "price": 9440000,
"claimAmount": 48000, "claimAmount": 384000,
"completionTimeInSecs": 12, "completionTimeInSecs": 12,
"reach": 3, "reach": 3,
"tracks": { "tracks": {
"yield": {"name": "Fine sieves", "levels": [ "yield": {"name": "Fine sieves", "levels": [
{"value": 56470, "price": 200000}, {"value": 452000, "price": 1600000},
{"value": 67760, "price": 260000}, {"value": 542000, "price": 2080000},
{"value": 76240, "price": 200000, "parts": {"Plasma Weave": 3}}, {"value": 610000, "price": 1600000, "parts": {"Plasma Weave": 3}},
{"value": 87530, "price": 260000, "parts": {"Void Core": 1}}, {"value": 700000, "price": 2080000, "parts": {"Void Core": 1}},
{"value": 96000, "price": 200000, "parts": {"Stellar Lattice": 1}} {"value": 768000, "price": 1600000, "parts": {"Stellar Lattice": 1}}
]}, ]},
"cycle": {"name": "Auger gearbox", "levels": [ "cycle": {"name": "Auger gearbox", "levels": [
{"value": 10.4, "price": 170000}, {"value": 10.4, "price": 1360000},
{"value": 9, "price": 200000}, {"value": 9, "price": 1600000},
{"value": 7.8, "price": 230000, "parts": {"Plasma Weave": 3}}, {"value": 7.8, "price": 1840000, "parts": {"Plasma Weave": 3}},
{"value": 6.8, "price": 250000, "parts": {"Void Core": 1}}, {"value": 6.8, "price": 2000000, "parts": {"Void Core": 1}},
{"value": 6, "price": 260000, "parts": {"Stellar Lattice": 1}} {"value": 6, "price": 2080000, "parts": {"Stellar Lattice": 1}}
]} ]}
} }
}, },
@@ -157,24 +157,24 @@
"name": "Electrostatic Dust Excavator EDE-700", "name": "Electrostatic Dust Excavator EDE-700",
"description": "Charged plates pull ore-rich dust straight out of the ground, no digging needed.", "description": "Charged plates pull ore-rich dust straight out of the ground, no digging needed.",
"image": "dustexcavator.png", "image": "dustexcavator.png",
"price": 2120000, "price": 17000000,
"claimAmount": 110000, "claimAmount": 880000,
"completionTimeInSecs": 14, "completionTimeInSecs": 14,
"reach": 5, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Larger plates", "levels": [ "yield": {"name": "Larger plates", "levels": [
{"value": 131390, "price": 390000}, {"value": 1050000, "price": 3120000},
{"value": 152780, "price": 390000}, {"value": 1220000, "price": 3120000},
{"value": 177220, "price": 450000, "parts": {"Void Core": 1}}, {"value": 1420000, "price": 3600000, "parts": {"Void Core": 1}},
{"value": 198610, "price": 390000, "parts": {"Stellar Lattice": 1}}, {"value": 1590000, "price": 3120000, "parts": {"Stellar Lattice": 1}},
{"value": 220000, "price": 390000, "parts": {"Void Core": 2, "Stellar Lattice": 2}} {"value": 1760000, "price": 3120000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
]}, ]},
"cycle": {"name": "Charge coils", "levels": [ "cycle": {"name": "Charge coils", "levels": [
{"value": 12.2, "price": 300000}, {"value": 12.2, "price": 2400000},
{"value": 10.5, "price": 370000}, {"value": 10.5, "price": 2960000},
{"value": 9.1, "price": 410000, "parts": {"Void Core": 1}}, {"value": 9.1, "price": 3280000, "parts": {"Void Core": 1}},
{"value": 8, "price": 430000, "parts": {"Stellar Lattice": 1}}, {"value": 8, "price": 3440000, "parts": {"Stellar Lattice": 1}},
{"value": 7, "price": 500000, "parts": {"Void Core": 2, "Stellar Lattice": 2}} {"value": 7, "price": 4000000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
]} ]}
} }
}, },
@@ -183,24 +183,24 @@
"name": "Lunar Core Extractor LCE-360", "name": "Lunar Core Extractor LCE-360",
"description": "A deep-core rig that bores to the mantle and hauls up what's been buried for eons.", "description": "A deep-core rig that bores to the mantle and hauls up what's been buried for eons.",
"image": "lunarcoreextractor.png", "image": "lunarcoreextractor.png",
"price": 3100000, "price": 24800000,
"claimAmount": 200000, "claimAmount": 1600000,
"completionTimeInSecs": 16, "completionTimeInSecs": 16,
"reach": 5, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Core barrel", "levels": [ "yield": {"name": "Core barrel", "levels": [
{"value": 238300, "price": 560000}, {"value": 1910000, "price": 4480000},
{"value": 280850, "price": 630000}, {"value": 2250000, "price": 5040000},
{"value": 319150, "price": 560000, "parts": {"Void Core": 2}}, {"value": 2550000, "price": 4480000, "parts": {"Void Core": 2}},
{"value": 361700, "price": 630000, "parts": {"Stellar Lattice": 2}}, {"value": 2890000, "price": 5040000, "parts": {"Stellar Lattice": 2}},
{"value": 400000, "price": 560000, "parts": {"Void Core": 3, "Stellar Lattice": 3}} {"value": 3200000, "price": 4480000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
]}, ]},
"cycle": {"name": "Drill string", "levels": [ "cycle": {"name": "Drill string", "levels": [
{"value": 14, "price": 420000}, {"value": 14, "price": 3360000},
{"value": 12, "price": 560000}, {"value": 12, "price": 4480000},
{"value": 10.4, "price": 600000, "parts": {"Void Core": 2}}, {"value": 10.4, "price": 4800000, "parts": {"Void Core": 2}},
{"value": 9.1, "price": 650000, "parts": {"Stellar Lattice": 2}}, {"value": 9.1, "price": 5200000, "parts": {"Stellar Lattice": 2}},
{"value": 8, "price": 710000, "parts": {"Void Core": 3, "Stellar Lattice": 3}} {"value": 8, "price": 5680000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
]} ]}
} }
} }
@@ -257,31 +257,31 @@
}, },
"refinery": { "refinery": {
"slotPrices": [1500, 4000, 10000, 25000, 60000, 150000, 400000, 1000000, 2500000], "slotPrices": [1500, 4000, 10000, 25000, 60000, 150000, 400000, 1000000, 2500000],
"maxBatch": 25, "maxBatch": 100,
"batchUpgrades": [ "batchUpgrades": [
{ "maxBatch": 50, "price": 6000 }, { "maxBatch": 250, "price": 6000 },
{ "maxBatch": 100, "price": 25000 }, { "maxBatch": 500, "price": 30000 },
{ "maxBatch": 250, "price": 120000 }, { "maxBatch": 1000, "price": 150000 },
{ "maxBatch": 500, "price": 600000 }, { "maxBatch": 2500, "price": 600000 },
{ "maxBatch": 1000, "price": 3000000 }, { "maxBatch": 5000, "price": 3000000 },
{ "maxBatch": 2500, "price": 15000000 } { "maxBatch": 10000, "price": 15000000 }
], ],
"bulkLog": 1.83, "bulkLog": 1.0,
"unlockGrade": "C", "unlockGrade": "C",
"items": [ "items": [
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 40, "seconds": 1, {"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 24, "seconds": 1,
"inputs": {"Sollux": 2, "Shadowstone": 2}, "inputs": {"Sollux": 2, "Shadowstone": 2},
"description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."}, "description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."},
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 113, "seconds": 2, {"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 97, "seconds": 2,
"inputs": {"Azurium": 3, "Novafor": 3}, "inputs": {"Azurium": 3, "Novafor": 3},
"description": "Azurium coolant sealed around a Novafor core keeps bits from melting at depth."}, "description": "Azurium coolant sealed around a Novafor core keeps bits from melting at depth."},
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 157, "seconds": 3, {"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 134, "seconds": 2,
"inputs": {"Nebulance": 4, "Sollux": 2}, "inputs": {"Nebulance": 4, "Sollux": 2},
"description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."}, "description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."},
{"name": "Void Core", "tier": 2, "color": "#c4b5fd", "price": 580, "seconds": 5, {"name": "Void Core", "tier": 2, "unlockGrade": "B", "color": "#c4b5fd", "price": 450, "seconds": 3,
"inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5}, "inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5},
"description": "A cold, dense power core that drives the heaviest rigs for weeks."}, "description": "A cold, dense power core that drives the heaviest rigs for weeks."},
{"name": "Stellar Lattice", "tier": 2, "color": "#fde047", "price": 630, "seconds": 6, {"name": "Stellar Lattice", "tier": 2, "unlockGrade": "B", "color": "#fde047", "price": 470, "seconds": 3,
"inputs": {"Plasma Weave": 2, "Cryo Cell": 1, "Shadowstone": 4}, "inputs": {"Plasma Weave": 2, "Cryo Cell": 1, "Shadowstone": 4},
"description": "A self-aligning crystal frame that lets deep-core extractors bore without drift."} "description": "A self-aligning crystal frame that lets deep-core extractors bore without drift."}
] ]
+37 -18
View File
@@ -1017,13 +1017,31 @@ func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) er
// on an empty well it goes to the moon's fullest one instead (Redrill, redeploy). // on an empty well it goes to the moon's fullest one instead (Redrill, redeploy).
reach := item.ToolReach() reach := item.ToolReach()
if supply <= 0 && reach > 1 { if supply <= 0 && reach > 1 {
err = tx.QueryRow(ctx, ` // Its well is empty: the next one with supply, in the moon's order (wrapping round),
select id, supply, (select name from resource where id = resource_id) from resource_well // as coverWells walks them.
where source_id = $1 and supply > 0 order by supply desc, id limit 1`, rows, err := tx.Query(ctx, `
moonID).Scan(&wellID, &supply, &resource) select resource_well.id, resource_well.supply, resource.name
if err != nil && !errors.Is(err, pgx.ErrNoRows) { from resource_well join resource on resource.id = resource_well.resource_id
where source_id = $1 order by resource.name, resource_well.id`, moonID)
if err != nil {
return err return err
} }
type w struct {
ID string
Supply float64
Resource string
}
wells, err := pgx.CollectRows(rows, pgx.RowToStructByPos[w])
if err != nil {
return err
}
at := slices.IndexFunc(wells, func(x w) bool { return x.ID == wellID })
for k := 1; k <= len(wells); k++ {
if x := wells[(max(at, 0)+k)%len(wells)]; x.Supply > 0 {
wellID, supply, resource = x.ID, x.Supply, x.Resource
break
}
}
} }
if supply <= 0 { if supply <= 0 {
return errWellEmpty return errWellEmpty
@@ -1325,22 +1343,23 @@ func redrillPick(amounts []float64, left float64) int {
} }
// coverWells picks the wells (indexes into supply) a stake with the given reach mines: // coverWells picks the wells (indexes into supply) a stake with the given reach mines:
// the one it was deployed on (anchor; -1 for none) first, then the others richest first, // the one it was deployed on (anchor; -1 for none) first, then its neighbours in the moon's
// leaving out empty ones, up to reach wells. // order (the next well, wrapping round), leaving out empty ones (in a projection, those
// the earlier claims empty: a well about to be tapped out is passed over), up to reach.
func coverWells(anchor int, supply []float64, reach int) []int { func coverWells(anchor int, supply []float64, reach int) []int {
var others []int n := len(supply)
for i, s := range supply { start := anchor
if i != anchor && s > 0 { if start < 0 || start >= n {
others = append(others, i) start = 0
}
var out []int
for k := 0; k < n && len(out) < max(1, reach); k++ {
i := (start + k) % n
if supply[i] > 0 {
out = append(out, i)
} }
} }
slices.SortStableFunc(others, func(a, b int) int { return cmpDesc(supply[a], supply[b]) }) return out
var out []int
if anchor >= 0 && anchor < len(supply) && supply[anchor] > 0 {
out = append(out, anchor)
}
out = append(out, others...)
return out[:min(len(out), max(1, reach))]
} }
// splitClaim is what a claim of amount takes from each well (indexes as in supply): from // splitClaim is what a claim of amount takes from each well (indexes as in supply): from
+5 -4
View File
@@ -9,6 +9,7 @@ package main
// - a gate passed: equipment (first deploy), market (first claim), store (first sale), // - a gate passed: equipment (first deploy), market (first claim), store (first sale),
// refinery (bought a moon of refinery.unlockGrade or higher), advisors (bought a tool); // refinery (bought a moon of refinery.unlockGrade or higher), advisors (bought a tool);
// - claimed:<resource>, the resources claimed so far; // - claimed:<resource>, the resources claimed so far;
// - grade:<G>, the moon grades bought so far (recipes with an unlockGrade, refinery.go);
// - seen:<tab>, the revealed tab was opened (no more shine); // - seen:<tab>, the revealed tab was opened (no more shine);
// - all: everything unlocked (players from before onboarding, admin "unlock all"). // - all: everything unlocked (players from before onboarding, admin "unlock all").
// Owning a moon is read from the game state. Redrill opens with the first claim. The store gate also reveals "+ New moon", // Owning a moon is read from the game state. Redrill opens with the first claim. The store gate also reveals "+ New moon",
@@ -101,14 +102,14 @@ func unlockClaim(ctx context.Context, tx pgx.Tx, userID, resource string) error
return unlock(ctx, tx, userID, "market", "claimed:"+resource) return unlock(ctx, tx, userID, "market", "claimed:"+resource)
} }
// unlockRefineryAt opens the refinery when the player buys a moon of refinery.unlockGrade // unlockRefineryAt records the grade of a moon bought (grade:<G>, for recipes with an
// or higher. // unlockGrade: recipeOpen) and opens the refinery at refinery.unlockGrade or higher.
func unlockRefineryAt(ctx context.Context, tx pgx.Tx, userID, grade string) error { func unlockRefineryAt(ctx context.Context, tx pgx.Tx, userID, grade string) error {
c := cfg() c := cfg()
if c.gradeIndex(grade) < c.gradeIndex(c.Refinery.UnlockGrade) { if c.gradeIndex(grade) < c.gradeIndex(c.Refinery.UnlockGrade) {
return nil return unlock(ctx, tx, userID, "grade:"+grade)
} }
return unlock(ctx, tx, userID, "refinery") return unlock(ctx, tx, userID, "refinery", "grade:"+grade)
} }
// markSeen clears the shine on a revealed tab once the player opens it. // markSeen clears the shine on a revealed tab once the player opens it.
+23 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"math" "math"
"math/rand" "math/rand"
"slices"
"testing" "testing"
"time" "time"
) )
@@ -52,7 +53,7 @@ func TestOverdrillSingle(t *testing.T) {
} }
func TestDrillsOnWideWell(t *testing.T) { func TestDrillsOnWideWell(t *testing.T) {
// A whole-moon drill on w0 (12 of 10 + 5 + 4) takes w0 then the richest (w1). // A whole-moon drill on w0 (12 of 10 + 5 + 4) takes w0 then the next well (w1).
m := odMoon(10, 5, 4) m := odMoon(10, 5, 4)
stakes := []Stake{odStake(1, "w0", 12, 3)} stakes := []Stake{odStake(1, "w0", 12, 3)}
s := &State{Stakes: stakes} s := &State{Stakes: stakes}
@@ -148,3 +149,24 @@ func TestWideTieOrder(t *testing.T) {
t.Fatalf("tie split %v", got) t.Fatalf("tie split %v", got)
} }
} }
// A multi-well drill mines its own well, then the next ones in the moon's order (not the
// richest), passing over empty ones and wrapping round.
func TestCoverWellsNext(t *testing.T) {
cases := []struct {
anchor int
supply []float64
reach int
want []int
}{
{0, []float64{10, 5, 90, 4}, 2, []int{0, 1}},
{1, []float64{10, 0, 5, 90}, 3, []int{2, 3, 0}},
{2, []float64{10, 5, 7, 0}, 3, []int{2, 0, 1}},
{3, []float64{10, 5, 7, 4}, 2, []int{3, 0}},
}
for _, c := range cases {
if got := coverWells(c.anchor, c.supply, c.reach); !slices.Equal(got, c.want) {
t.Errorf("coverWells(%d, %v, %d) = %v, want %v", c.anchor, c.supply, c.reach, got, c.want)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package main
import "testing"
// Void Core and Stellar Lattice open with the first B moon (or higher, or "all").
func TestRecipeOpen(t *testing.T) {
item := cfg().refinedItem("Void Core")
if item == nil || item.UnlockGrade != "B" {
t.Fatalf("Void Core should open at B: %+v", item)
}
cases := []struct {
flags []string
want bool
}{
{nil, false},
{[]string{"refinery", "grade:D", "grade:C"}, false},
{[]string{"grade:B"}, true},
{[]string{"grade:S"}, true},
{[]string{"all"}, true},
}
for _, c := range cases {
if got := recipeOpen(item, c.flags); got != c.want {
t.Errorf("recipeOpen(Void Core, %v) = %v, want %v", c.flags, got, c.want)
}
}
if !recipeOpen(cfg().refinedItem("Dusk Alloy"), nil) {
t.Error("Dusk Alloy has no unlock grade: open from the start")
}
}
+35
View File
@@ -14,7 +14,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"math" "math"
"slices"
"sort" "sort"
"strings"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -91,6 +93,24 @@ type RefinedItem struct {
Seconds int `json:"seconds"` Seconds int `json:"seconds"`
Inputs map[string]float64 `json:"inputs"` Inputs map[string]float64 `json:"inputs"`
Description string `json:"description"` Description string `json:"description"`
// The recipe shows (and can be refined) once the player has bought a moon of this grade
// or higher (onboarding flag grade:<G>, recipeOpen); "" from the start.
UnlockGrade string `json:"unlockGrade,omitempty"`
}
// recipeOpen says whether a recipe is available to a player with these onboarding flags.
func recipeOpen(item *RefinedItem, flags []string) bool {
if item.UnlockGrade == "" || slices.Contains(flags, "all") {
return true
}
c := cfg()
want := c.gradeIndex(item.UnlockGrade)
for _, f := range flags {
if g, ok := strings.CutPrefix(f, "grade:"); ok && c.gradeIndex(g) >= want {
return true
}
}
return false
} }
func (c *GameConfig) refinedItem(name string) *RefinedItem { func (c *GameConfig) refinedItem(name string) *RefinedItem {
@@ -115,6 +135,11 @@ func (c *GameConfig) unitPrice(name string) float64 {
func (cfg *GameConfig) validateRefinery() error { func (cfg *GameConfig) validateRefinery() error {
r := &cfg.Refinery r := &cfg.Refinery
for _, item := range r.Items {
if item.UnlockGrade != "" && cfg.gradeIndex(item.UnlockGrade) < 0 {
return fmt.Errorf("refinery %q: unlockGrade %q is not a moon grade", item.Name, item.UnlockGrade)
}
}
if r.UnlockGrade != "" && cfg.gradeIndex(r.UnlockGrade) < 0 { if r.UnlockGrade != "" && cfg.gradeIndex(r.UnlockGrade) < 0 {
return fmt.Errorf("refinery: unlockGrade %q is not a moon grade", r.UnlockGrade) return fmt.Errorf("refinery: unlockGrade %q is not a moon grade", r.UnlockGrade)
} }
@@ -350,6 +375,13 @@ func startRefine(ctx context.Context, db *pgxpool.Pool, userID, name string, qty
return 0, gameErr("Unknown recipe") return 0, gameErr("Unknown recipe")
} }
err := inTx(ctx, db, func(tx pgx.Tx) error { err := inTx(ctx, db, func(tx pgx.Tx) error {
var flags []string
if err := tx.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&flags); err != nil {
return err
}
if !recipeOpen(item, flags) {
return gameErr("Buy a %s moon to refine %s", item.UnlockGrade, item.Name)
}
slots, level, err := lockRefinery(ctx, tx, userID) slots, level, err := lockRefinery(ctx, tx, userID)
if err != nil { if err != nil {
return err return err
@@ -709,6 +741,9 @@ func (s *State) refineryPage() *RefineryPage {
} }
for i := range c.Refinery.Items { for i := range c.Refinery.Items {
item := &c.Refinery.Items[i] item := &c.Refinery.Items[i]
if !recipeOpen(item, s.Onboarding) {
continue // (shows once a moon of its grade is bought)
}
card := RecipeCard{Item: item, Inputs: s.partRows(item.Inputs), MaxQty: maxBatch, Owned: s.have(item.Name)} card := RecipeCard{Item: item, Inputs: s.partRows(item.Inputs), MaxQty: maxBatch, Owned: s.have(item.Name)}
for _, in := range card.Inputs { for _, in := range card.Inputs {
card.InputValue += in.Need * c.unitPrice(in.Name) card.InputValue += in.Need * c.unitPrice(in.Name)
+2 -2
View File
@@ -316,8 +316,8 @@ func sellTool(ctx context.Context, db *pgxpool.Pool, userID, toolID string) (str
if _, err := lockPlayer(ctx, tx, userID); err != nil { if _, err := lockPlayer(ctx, tx, userID); err != nil {
return err return err
} }
// A busy tool's idle copy is sold instead (see pickTool). // (that tool: a busy one is refused below)
toolID, err := pickTool(ctx, tx, userID, toolID, false) err := lockTool(ctx, tx, userID, toolID)
if err != nil { if err != nil {
return err return err
} }
@@ -0,0 +1,9 @@
-- The moon grades each player has bought, as onboarding flags (grade:<G>), for recipes
-- that open with a grade (refinery.go recipeOpen). Idempotent: adds what is missing.
update users set onboarding = array(select distinct unnest(onboarding || g.flags))
from (
select user_id, array_agg(distinct 'grade:' || grade) as flags
from staking_source where grade <> '' group by user_id
) g
where users.id = g.user_id
and not (users.onboarding @> g.flags);
+3 -3
View File
@@ -160,15 +160,15 @@ func (c *GameConfig) validateTracks(item StoreItem) error {
// ------------------------------------------------------------------ rule // ------------------------------------------------------------------ rule
// upgradeTool raises a tool's track of the kind (yield or cycle) a level (an idle copy of // upgradeTool raises the tool's track of the kind (yield or cycle) a level (that tool, also
// it, if there is one: see pickTool) and returns the tool it upgraded. // while it mines: from its next drill) and returns it.
func upgradeTool(ctx context.Context, db *pgxpool.Pool, userID, toolID, kind string) (string, error) { func upgradeTool(ctx context.Context, db *pgxpool.Pool, userID, toolID, kind string) (string, error) {
if kind != "yield" && kind != "cycle" { if kind != "yield" && kind != "cycle" {
return "", gameErr("Pick an upgrade track") return "", gameErr("Pick an upgrade track")
} }
err := inTx(ctx, db, func(tx pgx.Tx) error { err := inTx(ctx, db, func(tx pgx.Tx) error {
var err error var err error
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil { if err = lockTool(ctx, tx, userID, toolID); err != nil {
return err return err
} }
var storeItemID int var storeItemID int
+4 -53
View File
@@ -222,7 +222,6 @@ func (v View) resolve(s *State) View {
if v.Section != "equipment" || s.tool(v.Drill) == nil || !s.Unlocked("equipment") { if v.Section != "equipment" || s.tool(v.Drill) == nil || !s.Unlocked("equipment") {
v.Drill = "" v.Drill = ""
} }
v.Drill = s.idleCopy(v.Drill)
if v.Section != "advisors" || s.hire(v.Adv) == nil { if v.Section != "advisors" || s.hire(v.Adv) == nil {
v.Adv = "" v.Adv = ""
} }
@@ -890,9 +889,12 @@ func (s *State) page(v View) *Page {
sr := s.stakeRow(*st) sr := s.stakeRow(*st)
row.Stake = &sr row.Stake = &sr
} }
if row.Stake == nil {
row.Idle = 1
}
// (every tool on its own card, copies too)
p.Equipment = append(p.Equipment, row) p.Equipment = append(p.Equipment, row)
} }
p.Equipment = groupCopies(s, p.Equipment)
for _, row := range p.Equipment { for _, row := range p.Equipment {
if row.Selected { if row.Selected {
p.Drill = s.drillPanel(row) p.Drill = s.drillPanel(row)
@@ -954,21 +956,6 @@ func (s *State) copyKey(t Tool) string {
return fmt.Sprint(t.StoreItemID, t.Levels()) return fmt.Sprint(t.StoreItemID, t.Levels())
} }
// idleCopy is an idle copy of a busy tool (the tool itself if it's idle or has none), so
// selecting one of a group of copies never lands on a busy one while another is free.
func (s *State) idleCopy(id string) string {
t := s.tool(id)
if t == nil || s.StakeForTool(id) == nil {
return id
}
for _, c := range s.Tools {
if s.StakeForTool(c.ID) == nil && s.copyKey(c) == s.copyKey(*t) {
return c.ID
}
}
return id
}
// followTool keeps the selected drill on the tool an action used: the action may have // followTool keeps the selected drill on the tool an action used: the action may have
// used an idle copy (pickTool) of the one asked for. // used an idle copy (pickTool) of the one asked for.
func followTool(selected, asked, used string) string { func followTool(selected, asked, used string) string {
@@ -978,42 +965,6 @@ func followTool(selected, asked, used string) string {
return selected return selected
} }
// groupCopies merges copies of a tool into one row (Count, Idle), shown by the selected one
// if it's among them, else an idle one, else the one finishing first.
func groupCopies(s *State, rows []ToolRow) []ToolRow {
var out []ToolRow
at := map[string]int{}
better := func(a, b ToolRow) bool { // a shows the group rather than b
switch {
case a.Selected != b.Selected:
return a.Selected
case (a.Stake == nil) != (b.Stake == nil):
return a.Stake == nil
case a.Stake != nil:
return a.Stake.Remaining < b.Stake.Remaining
}
return false
}
for _, row := range rows {
if row.Stake == nil {
row.Idle = 1
}
key := s.copyKey(row.Tool)
i, ok := at[key]
if !ok {
at[key] = len(out)
out = append(out, row)
continue
}
n, idle := out[i].Count+row.Count, out[i].Idle+row.Idle
if better(row, out[i]) {
out[i] = row
}
out[i].Count, out[i].Idle = n, idle
}
return out
}
// StakeURL opens the well a stake is on. // StakeURL opens the well a stake is on.
func (p *Page) StakeURL(st *StakeRow) string { func (p *Page) StakeURL(st *StakeRow) string {
return View{Section: "moons", Moon: st.MoonID, Well: st.WellID}.URL() return View{Section: "moons", Moon: st.MoonID, Well: st.WellID}.URL()