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
-- card_col_min allows, like the web's grid; centered and capped by the caller), equal
-- 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).
--
-- 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).
local EPS = 1e-9
-- The wells a stake with this reach mines: its anchor first, then the richest (ties by
-- order), leaving out empty ones.
-- The wells a stake with this reach mines: its anchor first, then the next wells in the
-- moon's order (wrapping round), leaving out empty ones (game.go coverWells).
local function cover_wells(anchor, supply, reach)
local others = {}
for i, x in ipairs(supply) do
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 n = #supply
local start = (anchor and anchor >= 1 and anchor <= n) and anchor or 1
local out = {}
if anchor and supply[anchor] and supply[anchor] > 0 then
out[1] = anchor
end
for _, i in ipairs(others) do
out[#out + 1] = i
end
local n = math.min(#out, math.max(1, reach or 1))
for i = #out, n + 1, -1 do
out[i] = nil
local want = math.max(1, reach or 1)
for k = 0, n - 1 do
if #out >= want then
break
end
local i = (start - 1 + k) % n + 1
if supply[i] > 0 then
out[#out + 1] = i
end
end
return out
end
+2 -2
View File
@@ -8,8 +8,8 @@
-- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>",
-- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel" /
-- "dialog:<...>", "list:active|past", equipment cards "drill:<tool id>", buttons that ask
-- first "confirm:<action>", the gear "settings" (its menu: "mute", "theme", "motion",
-- "dialog:<...>", "list:active|past", equipment rows "drill:<tool id>", buttons that ask
-- 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
-- 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_panel_max = 460, -- tallest the market's detail and ticket panels get
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_min_w = 420, -- narrowest card with the image beside the text (narrower: image on top)
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;
-- or H.action / H.link). opts: variant ("primary", "secondary", "redrill", "danger",
-- "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.
function M.button(parent, label, x, y, opts)
opts = opts or {}
@@ -199,12 +199,16 @@ function M.button(parent, label, x, y, opts)
local parts = { { text, M.text_width(text) } }
if opts.price then
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 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 }
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
if opts.bare then -- (no "·" between: "+ ₥290")
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 }
end
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
-- 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
-- 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 ui = require("modules.ui")
local moon_view = require("modules.moon.moon")
local reveal = require("screens.game.reveal")
local moons = require("screens.game.moons")
local moon_sale = require("screens.game.moon_sale")
local panel = require("components.panel")
local scroll = require("components.scroll")
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 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):
-- thumbnail, name and track levels; what it gets; time left or Ready.
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))
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)
fg.build(flex.solve(tree, pad, top), c)
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, "Idle drills", tostring(idle)),
}
if st.unlocked.store and moon.sale then
kids[#kids + 1] = fact(H, "Sells for", moon.sale.total, { credits = true })
for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
kids[#kids + 1] = k
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
-- 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
caption(root, pad, cy, inner, moon.name, "Mined out. Nothing left to dig.")
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
mod.aside_below(g, root, pad, cy, inner, limit, H)
elseif section == "new-moon" then
+4 -2
View File
@@ -87,14 +87,16 @@ end
---------------------------------------------------------------- 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
-- 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
-- the first state and after the reveal closes (g.enter_all).
function enter(g)
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 all = g.enter_all or not g.entered
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
-- track levels share one) with its image, levels chip, status, stats (yield, cycle) and
-- 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, apart from money), the Upgrade button (or
-- Max level in its place) and a need line kept whether it shows, so the buttons never
-- move (clicker rule). A card opens the drill panel (view drill=<tool id>) in the aside:
-- stats, the two tracks, each track's ladder, idle copies and their best well, and
-- selling the drill. Before the Store is unlocked (onboarding) cards and panel leave out
-- the Store's parts (upgrades, selling): the panel is stats, ladders and idle copies.
-- The Equipment section (state.equipment): a table, a row per tool (copies too), with its
-- image, name, copy number and levels, status, and its two upgrade tracks in columns (value
-- now ▸ after the next level, stat_up violet; level pips), each track's Upgrade button (or
-- Max) and Sell under them, notes under those (the table's comment, below). A row opens the
-- drill panel (view drill=<tool id>) in the aside: stats, the two tracks, each track's
-- ladder, idle copies and their best well, and selling the drill. Before the Store is
-- unlocked (onboarding) rows and panel leave out the Store's parts (upgrades, selling): the
-- panel is stats, ladders and idle copies.
local theme = require("modules.theme")
local ui = require("modules.ui")
@@ -14,8 +13,8 @@ local tooltip = require("modules.tooltip")
local cards = require("screens.game.cards")
local scroll = require("components.scroll")
local store = require("screens.game.store")
local chip = require("components.chip")
local card = require("components.card")
local row_c = require("components.row")
local blocks = require("modules.blocks")
local flex = require("modules.flex")
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)
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).
local function track_value(tr, 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)
end
-- The same at (x, y) (a card's footer); returns the height used.
local function tracks(self, H, st, parent, t, x, y, w)
local tree = tracks_box(self, H, st, parent, t, w)
fg.build(flex.solve(tree, x, y), parent)
return tree.h
---------------------------------------------------------------- the table
-- One row per tool (copies are rows of their own), fixed columns: image | name, with
-- "#<copy> · Y2 C1" under it (the copy's number among tools of its type, its levels) |
-- 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
-- A tool's status (a 20 tall row at (x, y - 10), w wide): where its drill is (its
-- resource dot, "<moon> · <resource>", returned as a link target) and time left or Ready,
-- or how many copies are idle.
local function status(self, H, st, parent, t, x, y, w)
local d = t.stake
local kids, where_link = {}, nil
if d then
local moon = find(st.moons, "id", d.moonId)
local where = (moon and (moon.name .. " · ") or "") .. d.resource
local lbl = fg.label(parent, where, { font = "sans", px = 13, color = "text_2" }, 20,
{ fit = true, shrink = 1, max_width = w - 14 - 110 })
lbl.done = function(n, b)
where_link = { node = n, x = x, y = y, w = b.w + 14, view = { section = "moons", moon = d.moonId, well = d.wellId } }
local function row_h(open, wide)
if open then
return PADY * 2 + LINE1 + 6 + S.button_sm_h + 2 + NOTE
end
return PADY * 2 + LINE1 + (wide and 0 or 26)
end
-- Column captions.
local function header(c, geo)
local function cap(text, w)
return fg.label(c, text, HEAD, 14, { width = w, shrink = 0, fit = true })
end
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
local right
if d.ready then
right = fg.label(parent, "Ready", { font = "sans_semi", px = 13, color = "ready" }, 20)
else
right = fg.label(parent, ui.dur(d["end"] - H.now(self)) .. " left", { font = "mono", px = 12, color = "muted" }, 20)
right.done = function(n)
H.tick_text(self, n, d["end"], function(left)
return ui.dur(left) .. " left"
end, "due")
tip = tip .. ": " .. theme.currency .. ui.money(n.price) .. " + " .. table.concat(ps, ", ")
end
return fg.button(c, "+", { small = true, bare = true, price = n.price, disabled = not ok }, function(b, nodes)
tooltip.add(b, tip)
if ok then
cards.pulse(nodes)
H.action(self, b, nodes, "upgrade", { tool = t.id, track = tr.kind })
end
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
kids = {
made({ width = 8, height = 8, shrink = 0 }, function(p, rx, ry) ui.rect(p, rx, ry, 8, 8, d.resource) end),
space(6), lbl, fg.fill(), right,
t.count > 1 and fg.label(parent, " · all " .. t.count .. " drilling", { font = "sans", px = 13, color = "muted" }, 20) or nil,
}
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
local lx = right and (bw - tw * k) or math.floor((bw - tw * k) / 2)
gui.set_position(n, vec(x + math.floor(lx), -(y + NOTE / 2)))
tooltip.add(n, tip)
end)
end
local function tool_card(self, H, st, c, t, open)
c:image(t.image)
-- Name, ×count, levels chip on the right (none for a base tool).
local tag = ui.levels_tag(t)
c:block(22, function(p, bw)
local cnt = t.count > 1 and ("×" .. t.count) or nil
fg.build(flex.solve(row({ width = bw, height = 16, align = "center" }, {
fg.label(p, t.name, { font = "sans_semi", px = 15, color = "text", tracking = 0.1, upper = true }, 16, { fit = true, shrink = 1 }),
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)
-- Line 3 of a track's column: credits short (the server's shortText), else the parts the
-- next level uses ("+ 2 Dusk Alloy", "+ 2 parts"; red when short), else blank.
local function up_note(c, tr, w)
local n = tr and tr.next
local blank = space(w, NOTE)
blank.blank = true
if not n then
return blank
end
c:block(function(p, bw)
ui.rect(p, 0, 0, bw, 1, "line")
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))
if n.blocked == "credits" and (n.shortText or "") ~= "" then
return note_text(n.shortText, "danger", w, n.blockedText)
end
if open then
-- The two upgrade tracks: measured on a probe; the footer sits at the row's bottom.
local probe = ui.rect(c.root, 0, 0, 1, 1, "none")
local fh = tracks(self, { action = function() end }, st, probe, t, 0, 0, c.bw)
gui.delete_node(probe)
c:footer(fh + 2, function(f, bw)
tracks(self, H, st, f, t, 0, 2, bw)
local parts = n.parts or {}
if #parts == 0 then
return blank
end
local short, ps = false, {}
for _, pt in ipairs(parts) do
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, { 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
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
function M.build(self, root, px, py, w, h, H)
local st = self.state
local eq = st.equipment
local open = st.unlocked.store
local area, x, inner, y = cards.page(self, root, px, py, w, h, "equipment")
local area, x, inner, y = cards.page(self, root, px, py, w, h, "equipment", TABLE_MAX)
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)
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 ""),
@@ -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)
#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 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)
tool_card(self, H, st, cd, t, open)
end, { key = function(t) return "eq:tool:" .. tostring(t.id) end, sig = function(t)
-- (kept while what it shows stays the same: modules/blocks)
if #eq.tools > 0 then
ui.rect(c, x, y, inner, 1, "line_hi")
y = y + 1
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)
return { open, moon and moon.name or false, st.refined, st.credits, st.resources, st.catalogVersion, self.inflight, self.view }
end })
-- (kept while what it shows stays the same: modules/blocks)
local sig = blocks.sig(x, y, inner, t, copy, conf, open, moon and moon.name or false, st.refined, st.credits,
st.resources, st.catalogVersion, self.inflight, self.view, self.ask and self.ask.id or false)
local ry = y
blocks.keep(self, "eq:tool:" .. tostring(t.id), sig, c, function(r)
fg.build(flex.solve(tool_row(self, H, c, st, t, copy, geo, inner, open, conf), x, ry), r)
end)
y = y + rh
end
area:finish(y + py)
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),
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)
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),
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
-- 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)
local sale = moon.sale
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.
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
-- 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 })
@@ -60,11 +65,24 @@ function M.box(self, H, c, moon, w)
gui.set_euler(chev, vmath.vector3(0, 0, -90))
end
end),
row({ height = 48, padding = { 0, 140, 0, 30 }, align = "center" }, {
fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16), space(8),
fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16), space(8),
inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end), space(10),
fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 }),
row({ height = 48, padding = { 0, 156, 0, 30 }, align = "center" }, {
-- (the label and the offer keep their size: the delta gives way, cut to fit)
(function()
local l = fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16)
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)
if not sale.canSell and (sale.whyText or "") ~= "" then
@@ -161,7 +179,12 @@ function M.box(self, H, c, moon, w)
})
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)
ui.outline(b, 0, 0, bw, bh, "edge")
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 test = require("modules.test")
local predict = require("modules.predict")
local moon_sale = require("screens.game.moon_sale")
local S = theme.size
@@ -187,10 +186,10 @@ end
-- the aside shows its facts.
--
-- 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
-- tray (g.deploy_pick), or with none picked the first one-well drill in the tray that
-- has room there (smallest first; predict.room: what it would get after the moon's drills). Picking is
-- a toggle; the pick is forgotten once that group is used up.
-- name, track levels, yield, cycle and reach). Clicking a tile deploys one of them to the
-- selected well (none selected: the first well it has room on). A well's deploy slot sends
-- the first drill in the tray that has room there, multi-well ones too (smallest first;
-- predict.room: what it would get after the moon's drills).
local SLOT_H, SLOT_GAP = 60, 6
local CARD_MIN, CARD_GAP, CARD_PAD = 210, 12, 12
@@ -278,11 +277,8 @@ local function deploy_choice(g, st, well, groups)
end
end
for _, grp in ipairs(groups) do
-- (a multi-well drill goes out only when picked: it mines other wells too)
local here, amount = 0, 0
if (grp.tool.reach or 1) <= 1 then
here, amount = gets_here(st, well, grp.tool)
end
-- (multi-well drills too: they deploy here like any other, then mine the next wells)
local here, amount = gets_here(st, well, grp.tool)
if here > EPS then
return grp, here, amount, false
end
@@ -337,7 +333,7 @@ local function deploy_slot(g, st, well, groups, H)
if ok then
local params = { tool = grp.tool.id, target = well.id }
H.action(g, b, bn, "stake", params)
tooltip.add(b, "Deploy " .. (grp.tool.name or "a drill") .. (explicit and "" or " (the smallest idle 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
@@ -456,7 +452,9 @@ local function idle_slot(g, st, well, H)
end
end
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)
local body = ui.rect(p, x, y, w, SLOT_H, "cyan", 0.02)
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 })
tooltip.add(b, "Send the drills that last mined this well out again")
end, { height = SLOT_BTN_H }) or row({ height = SLOT_BTN_H, align = "center" }, {
fit(body, well.supply > 0 and "Last mined here" 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)
@@ -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)
end
local n, next_d = 0, next_drill(g, well.drills)
-- Tapped out once the ready drills are claimed (nothing left, or what is left is the
-- other drills' on it, the server's `remain`): Claim only, no Redeploy.
local tapped = well.supply - ready.amount(g, well, well.cap) <= 1e-9 or (well.remain or 1) <= 1e-9
for _, d in ipairs(well.drills or {}) do
local due = d.ready or (d["end"] or math.huge) <= H.now(g)
n = n + (due and 1 or 0)
slots[#slots + 1] = drill_slot(g, st, d, scope, not dry and 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
if (well.redrill or 0) > 0 then
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 scope = { ["claim-wide"] = moon.id }
local slots, n, next_d = {}, 0, next_drill(g, wide.drills)
-- (the moon tapped out once these are claimed: no Redeploy)
local left = 0
for _, w in ipairs(moon.wells or {}) do
left = left + w.supply
end
local tapped = left - ready.amount(g, wide, wide.cap) <= 1e-9
for _, d in ipairs(wide.drills or {}) do
local due = d.ready or (d["end"] or math.huge) <= H.now(g)
n = n + (due and 1 or 0)
slots[#slots + 1] = drill_slot(g, st, d, scope, 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
local sw = inner - CARD_PAD * 2
return col({ width = inner, padding = CARD_PAD }, {
@@ -581,7 +589,7 @@ end
local function tray(g, c, st, groups, inner, H)
local head = row({ height = 16, align = "center" }, {
fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0),
#groups > 0 and fit(c, 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
return col({ width = inner }, {
@@ -595,12 +603,30 @@ local function tray(g, c, st, groups, inner, H)
}),
})
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 = {}
for _, grp in ipairs(groups) do
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)
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")
if t.image then
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
ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9)
end
tooltip.add(b, (t.name or "Drill") .. (picked and " · picked (click to unpick)" or " · pick it for + Deploy"))
button.bind(g, b, bn, { id = "pick:" .. grp.key, label = M.short_name(t), on_click = function()
g.deploy_pick = (not picked) and grp.key or nil
g.dirty = true
end })
if ok then
tooltip.add(b, "Deploy " .. (t.name or "a drill") .. " to the " .. well.resource .. " well")
H.action(g, b, bn, "stake", { tool = t.id, target = well.id })
else
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
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
kids[#kids + 1] = space(0, 20)
kids[#kids + 1] = tray(g, c, st, groups, inner, H)
-- 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
-- (selling the moon, a destructive action, is in the aside: aside.lua)
local page = col({ width = inner, min_height = min_h }, kids)
fg.build(flex.solve(page, px, py), c)
@@ -749,7 +769,8 @@ end
-- Section contract (AGENTS.md, "Game screen"): root is the main panel, (px, py) its
-- padding, w x h its size.
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
local st = g.state
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
-- a collect's motes land there and the number counts up. Laid out at the final amount.
local counted = {}
local function own_label(c, r)
local l = fg.label(c, ui.money(r.owned), CAP_NUM, 24)
counted[#counted + 1] = { key = "refined:" .. r.item, node = l.node, amount = r.owned }
local function own_label(c, r, color)
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 or 0 }
return l
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 owned = (r.owned or 0) > 0
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N ·
-- owned X" over the name.
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N" over
-- 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 g = ui.rect(p, x, y, 36, 36, color, 0.85)
gui.play_flipbook(g, "pentagon")
@@ -510,13 +511,14 @@ local function recipe_card(self, H, c, st, rf, r)
end }
local head = row({ height = 43.3, gap = 12, align = "start" }, {
col({ padding_top = 3.6 }, { glyph }),
col({ grow = 1, align = "start" }, {
row({}, {
fg.label(c, "Tier " .. r.tier .. (owned and " · owned " or ""), "cap", 24),
owned and own_label(c, r) or nil,
}),
col({ grow = 1, shrink = 1, align = "start" }, {
fg.label(c, "Tier " .. r.tier, "cap", 24),
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).
@@ -558,32 +560,34 @@ local function recipe_card(self, H, c, st, rf, r)
return n
end })
-- The form: quantity and Max on a row, Refine the card's width under them (beside the
-- quantity and "Max 250" it was cramped).
-- The form: the quantity and Refine (that many) on a row, Max (as many as you can, the
-- usual click) the card's width under them, primary.
local qty, input = qty_field(self, r, off, rf.maxBatch)
qty.style.width, qty.style.grow = nil, 1
local refine
local refine_btn = btn("Refine", BTN_SM, { primary = true, disabled = off }, function(b, nodes)
if off then
return tooltip.add(b, why)
end
refine = H.action(self, b, nodes, "refine", function()
local q = self.refine_qty and self.refine_qty[r.item] or "1"
return { recipe = r.item, qty = (q ~= "" and q or "1") }
end)
-- Enter in the quantity refines (the web's form).
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
end, { grow = 1 })
local form = col({ shrink = 0 }, { row({ height = BTN_SM, gap = 6, shrink = 0 }, {
qty,
btn(off and "Max" or ("Max " .. r.maxQty), BTN_SM, { disabled = off }, function(b, nodes)
if off then
return tooltip.add(b, why)
end
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
end),
}), space(0, 6), row({ height = BTN_SM, shrink = 0 }, { refine_btn }) })
local refine_btn = btn("Refine", BTN_SM, { disabled = off }, function(b, nodes)
if off then
return tooltip.add(b, why)
end
refine = H.action(self, b, nodes, "refine", function()
local q = self.refine_qty and self.refine_qty[r.item] or "1"
return { recipe = r.item, qty = (q ~= "" and q or "1") }
end)
tooltip.add(b, "Refine the number in the box")
-- Enter in the quantity refines (the web's form).
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
end)
local max_btn = btn(off and "Max" or ("Max · " .. ui.money(r.maxQty)), BTN_SM, { primary = true, disabled = off }, function(b, nodes)
if off then
return tooltip.add(b, why)
end
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
end, { grow = 1 })
local form = col({ shrink = 0 }, {
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)
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
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:
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,
the same panel has them. Shots "before" and "after", each linted."""
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
cycle, side by side: level, value ▸ next, Upgrade or Max level, the kept need line) and the
"""Drill upgrade tracks (needs --fixture): the Equipment table's two track columns (yield and
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).
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."""
+28 -20
View File
@@ -1,11 +1,12 @@
"""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:
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
keeps its spot: click_check); a multi-well drill (the wide card); finishes the drills: the
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
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
aside, with a well and with none selected, has no buttons (aside-well, aside-moon).
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 but Sell moon, the one destructive
action kept there (aside-well, aside-moon).
Args: an optional name prefix."""
import os
import sys
@@ -26,7 +27,7 @@ def aside_buttons(game):
r = e.get("rect")
if not r or not e.get("visible") or e.get("kind") == "wait":
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"])
return out
@@ -52,21 +53,26 @@ def run(game, args):
game.shot(pre + "cards-empty", settle=0.8)
game.lint(pre + "cards-empty")
# Pick the last tile in the tray (one-well), deploy it from the first well's slot.
tiles = ids(game, "pick:")
game.expect(len(tiles) > 0, "the tray has idle drills")
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)
# A tray tile deploys straight to the selected well (no picking): select the first well,
# click the lowest "stake" button there (the tray sits under the cards).
game.navigate("{section='moons', moon='%s', well='%s'}" % (moon["id"], wells[0]))
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]
game.scroll_to(ids(game, slot)[0])
game.click_check(ids(game, slot)[0], name=pre + "click-deploy")
if ids(game, slot):
game.click_check(ids(game, slot)[0], name=pre + "click-deploy-2")
btns = [e for e in game.elements() if e["id"].startswith(slot) and e.get("visible", True)]
game.expect(len(btns) > 1, "the tray's tiles deploy to the selected well")
n0 = game.eval("#st.stakes")
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()
# 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)
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])
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:
game.click(redrill[0])
game.wait_for("not g.busy and #g.pending == 0", 20)
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)
left = aside_buttons(game)
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)
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 |
|---|---|---|
| `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 |
| `sell-tool` | `tool` | idle, nothing to claim, not the last tool (`noSale`) |
| `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` | 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`) |
| `collect` | `job` (job id), or none for every finished batch | |
| `buy-slot` | – | refinery slot |
@@ -167,8 +167,8 @@ short code, listed with each field, and the web's text next to it.
drill}
```
- Copies of a tool (same type and track levels) are one row: `count` copies, `idle` of them
idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill.
- Every tool is its own row, copies too: `count` is 1, `idle` 1 when it isn't mining, `stake`
its drill.
- `tracks`: the tool's two upgrade tracks, yield then cycle: `{kind, name, level, max, value,
next, ladder}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds
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
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
own well (if not empty) then the others with something left, most first (ties in
`moon.wells` order), up to `reach` wells, taking from each in turn as much as is left
own well (if not empty) then the next wells in `moon.wells` order (wrapping round), each
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
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
+89 -89
View File
@@ -5,13 +5,13 @@
"moonGrades": [
{"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}},
{"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}},
{"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}},
{"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}},
{"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}}
],
"moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600},
@@ -55,23 +55,23 @@
"name": "Percussion Drill PX-4",
"description": "Hammers the crust a thousand times a second to crack open ore seams.",
"image": "pickaxe.png",
"price": 4000,
"claimAmount": 60,
"price": 8000,
"claimAmount": 120,
"completionTimeInSecs": 6,
"tracks": {
"yield": {"name": "Wider chisel", "levels": [
{"value": 72, "price": 760},
{"value": 84, "price": 760},
{"value": 96, "price": 760, "parts": {"Dusk Alloy": 2}},
{"value": 108, "price": 760, "parts": {"Cryo Cell": 1}},
{"value": 120, "price": 760, "parts": {"Plasma Weave": 1}}
{"value": 144, "price": 1520},
{"value": 168, "price": 1520},
{"value": 192, "price": 1520, "parts": {"Dusk Alloy": 2}},
{"value": 216, "price": 1520, "parts": {"Cryo Cell": 1}},
{"value": 240, "price": 1520, "parts": {"Plasma Weave": 1}}
]},
"cycle": {"name": "Pneumatic hammer", "levels": [
{"value": 5.2, "price": 580},
{"value": 4.5, "price": 680},
{"value": 3.9, "price": 780, "parts": {"Dusk Alloy": 2}},
{"value": 3.4, "price": 860, "parts": {"Cryo Cell": 1}},
{"value": 3, "price": 890, "parts": {"Plasma Weave": 1}}
{"value": 5.2, "price": 1160},
{"value": 4.5, "price": 1360},
{"value": 3.9, "price": 1560, "parts": {"Dusk Alloy": 2}},
{"value": 3.4, "price": 1720, "parts": {"Cryo Cell": 1}},
{"value": 3, "price": 1780, "parts": {"Plasma Weave": 1}}
]}
}
},
@@ -80,23 +80,23 @@
"name": "LunarLite L100",
"description": "An entry-level rotary drill: point it down and the ore rolls in.",
"image": "lunarlite.jpg",
"price": 13400,
"claimAmount": 300,
"price": 40200,
"claimAmount": 900,
"completionTimeInSecs": 8,
"tracks": {
"yield": {"name": "Wide-bore bit", "levels": [
{"value": 355, "price": 2300},
{"value": 423, "price": 2900},
{"value": 477, "price": 2300, "parts": {"Cryo Cell": 1}},
{"value": 545, "price": 2900, "parts": {"Plasma Weave": 2}},
{"value": 600, "price": 2300, "parts": {"Plasma Weave": 3}}
{"value": 1060, "price": 6900},
{"value": 1270, "price": 8700},
{"value": 1430, "price": 6900, "parts": {"Cryo Cell": 1}},
{"value": 1640, "price": 8700, "parts": {"Plasma Weave": 2}},
{"value": 1800, "price": 6900, "parts": {"Plasma Weave": 3}}
]},
"cycle": {"name": "Spindle motor", "levels": [
{"value": 7, "price": 1800},
{"value": 6, "price": 2400},
{"value": 5.2, "price": 2600, "parts": {"Cryo Cell": 1}},
{"value": 4.6, "price": 2600, "parts": {"Plasma Weave": 2}},
{"value": 4, "price": 3300, "parts": {"Plasma Weave": 3}}
{"value": 7, "price": 5400},
{"value": 6, "price": 7200},
{"value": 5.2, "price": 7800, "parts": {"Cryo Cell": 1}},
{"value": 4.6, "price": 7800, "parts": {"Plasma Weave": 2}},
{"value": 4, "price": 9900, "parts": {"Plasma Weave": 3}}
]}
}
},
@@ -105,24 +105,24 @@
"name": "LunarLite L300",
"description": "The L100's laser-cutting big brother, built to burn through bedrock.",
"image": "lunarlite300.png",
"price": 129000,
"claimAmount": 4000,
"price": 645000,
"claimAmount": 20000,
"completionTimeInSecs": 10,
"reach": 2,
"tracks": {
"yield": {"name": "Wider beam", "levels": [
{"value": 4740, "price": 23000},
{"value": 5630, "price": 27000},
{"value": 6370, "price": 23000, "parts": {"Cryo Cell": 2}},
{"value": 7260, "price": 27000, "parts": {"Plasma Weave": 3}},
{"value": 8000, "price": 23000, "parts": {"Void Core": 1}}
{"value": 23700, "price": 115000},
{"value": 28200, "price": 135000},
{"value": 31800, "price": 115000, "parts": {"Cryo Cell": 2}},
{"value": 36300, "price": 135000, "parts": {"Plasma Weave": 3}},
{"value": 40000, "price": 115000, "parts": {"Void Core": 1}}
]},
"cycle": {"name": "Laser capacitors", "levels": [
{"value": 8.7, "price": 18000},
{"value": 7.5, "price": 23000},
{"value": 6.5, "price": 25000, "parts": {"Cryo Cell": 2}},
{"value": 5.7, "price": 26000, "parts": {"Plasma Weave": 3}},
{"value": 5, "price": 30000, "parts": {"Void Core": 1}}
{"value": 8.7, "price": 90000},
{"value": 7.5, "price": 115000},
{"value": 6.5, "price": 125000, "parts": {"Cryo Cell": 2}},
{"value": 5.7, "price": 130000, "parts": {"Plasma Weave": 3}},
{"value": 5, "price": 150000, "parts": {"Void Core": 1}}
]}
}
},
@@ -131,24 +131,24 @@
"name": "Lunar Regolith Extractor LRE-3000",
"description": "An auger rig that chews through regolith by the ton and keeps every grain worth selling.",
"image": "regolith.jpg",
"price": 1180000,
"claimAmount": 48000,
"price": 9440000,
"claimAmount": 384000,
"completionTimeInSecs": 12,
"reach": 3,
"tracks": {
"yield": {"name": "Fine sieves", "levels": [
{"value": 56470, "price": 200000},
{"value": 67760, "price": 260000},
{"value": 76240, "price": 200000, "parts": {"Plasma Weave": 3}},
{"value": 87530, "price": 260000, "parts": {"Void Core": 1}},
{"value": 96000, "price": 200000, "parts": {"Stellar Lattice": 1}}
{"value": 452000, "price": 1600000},
{"value": 542000, "price": 2080000},
{"value": 610000, "price": 1600000, "parts": {"Plasma Weave": 3}},
{"value": 700000, "price": 2080000, "parts": {"Void Core": 1}},
{"value": 768000, "price": 1600000, "parts": {"Stellar Lattice": 1}}
]},
"cycle": {"name": "Auger gearbox", "levels": [
{"value": 10.4, "price": 170000},
{"value": 9, "price": 200000},
{"value": 7.8, "price": 230000, "parts": {"Plasma Weave": 3}},
{"value": 6.8, "price": 250000, "parts": {"Void Core": 1}},
{"value": 6, "price": 260000, "parts": {"Stellar Lattice": 1}}
{"value": 10.4, "price": 1360000},
{"value": 9, "price": 1600000},
{"value": 7.8, "price": 1840000, "parts": {"Plasma Weave": 3}},
{"value": 6.8, "price": 2000000, "parts": {"Void Core": 1}},
{"value": 6, "price": 2080000, "parts": {"Stellar Lattice": 1}}
]}
}
},
@@ -157,24 +157,24 @@
"name": "Electrostatic Dust Excavator EDE-700",
"description": "Charged plates pull ore-rich dust straight out of the ground, no digging needed.",
"image": "dustexcavator.png",
"price": 2120000,
"claimAmount": 110000,
"price": 17000000,
"claimAmount": 880000,
"completionTimeInSecs": 14,
"reach": 5,
"tracks": {
"yield": {"name": "Larger plates", "levels": [
{"value": 131390, "price": 390000},
{"value": 152780, "price": 390000},
{"value": 177220, "price": 450000, "parts": {"Void Core": 1}},
{"value": 198610, "price": 390000, "parts": {"Stellar Lattice": 1}},
{"value": 220000, "price": 390000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
{"value": 1050000, "price": 3120000},
{"value": 1220000, "price": 3120000},
{"value": 1420000, "price": 3600000, "parts": {"Void Core": 1}},
{"value": 1590000, "price": 3120000, "parts": {"Stellar Lattice": 1}},
{"value": 1760000, "price": 3120000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
]},
"cycle": {"name": "Charge coils", "levels": [
{"value": 12.2, "price": 300000},
{"value": 10.5, "price": 370000},
{"value": 9.1, "price": 410000, "parts": {"Void Core": 1}},
{"value": 8, "price": 430000, "parts": {"Stellar Lattice": 1}},
{"value": 7, "price": 500000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
{"value": 12.2, "price": 2400000},
{"value": 10.5, "price": 2960000},
{"value": 9.1, "price": 3280000, "parts": {"Void Core": 1}},
{"value": 8, "price": 3440000, "parts": {"Stellar Lattice": 1}},
{"value": 7, "price": 4000000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
]}
}
},
@@ -183,24 +183,24 @@
"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.",
"image": "lunarcoreextractor.png",
"price": 3100000,
"claimAmount": 200000,
"price": 24800000,
"claimAmount": 1600000,
"completionTimeInSecs": 16,
"reach": 5,
"tracks": {
"yield": {"name": "Core barrel", "levels": [
{"value": 238300, "price": 560000},
{"value": 280850, "price": 630000},
{"value": 319150, "price": 560000, "parts": {"Void Core": 2}},
{"value": 361700, "price": 630000, "parts": {"Stellar Lattice": 2}},
{"value": 400000, "price": 560000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
{"value": 1910000, "price": 4480000},
{"value": 2250000, "price": 5040000},
{"value": 2550000, "price": 4480000, "parts": {"Void Core": 2}},
{"value": 2890000, "price": 5040000, "parts": {"Stellar Lattice": 2}},
{"value": 3200000, "price": 4480000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
]},
"cycle": {"name": "Drill string", "levels": [
{"value": 14, "price": 420000},
{"value": 12, "price": 560000},
{"value": 10.4, "price": 600000, "parts": {"Void Core": 2}},
{"value": 9.1, "price": 650000, "parts": {"Stellar Lattice": 2}},
{"value": 8, "price": 710000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
{"value": 14, "price": 3360000},
{"value": 12, "price": 4480000},
{"value": 10.4, "price": 4800000, "parts": {"Void Core": 2}},
{"value": 9.1, "price": 5200000, "parts": {"Stellar Lattice": 2}},
{"value": 8, "price": 5680000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
]}
}
}
@@ -257,31 +257,31 @@
},
"refinery": {
"slotPrices": [1500, 4000, 10000, 25000, 60000, 150000, 400000, 1000000, 2500000],
"maxBatch": 25,
"maxBatch": 100,
"batchUpgrades": [
{ "maxBatch": 50, "price": 6000 },
{ "maxBatch": 100, "price": 25000 },
{ "maxBatch": 250, "price": 120000 },
{ "maxBatch": 500, "price": 600000 },
{ "maxBatch": 1000, "price": 3000000 },
{ "maxBatch": 2500, "price": 15000000 }
{ "maxBatch": 250, "price": 6000 },
{ "maxBatch": 500, "price": 30000 },
{ "maxBatch": 1000, "price": 150000 },
{ "maxBatch": 2500, "price": 600000 },
{ "maxBatch": 5000, "price": 3000000 },
{ "maxBatch": 10000, "price": 15000000 }
],
"bulkLog": 1.83,
"bulkLog": 1.0,
"unlockGrade": "C",
"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},
"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},
"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},
"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},
"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},
"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).
reach := item.ToolReach()
if supply <= 0 && reach > 1 {
err = tx.QueryRow(ctx, `
select id, supply, (select name from resource where id = resource_id) from resource_well
where source_id = $1 and supply > 0 order by supply desc, id limit 1`,
moonID).Scan(&wellID, &supply, &resource)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
// Its well is empty: the next one with supply, in the moon's order (wrapping round),
// as coverWells walks them.
rows, err := tx.Query(ctx, `
select resource_well.id, resource_well.supply, resource.name
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
}
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 {
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:
// the one it was deployed on (anchor; -1 for none) first, then the others richest first,
// leaving out empty ones, up to reach wells.
// the one it was deployed on (anchor; -1 for none) first, then its neighbours in the moon's
// 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 {
var others []int
for i, s := range supply {
if i != anchor && s > 0 {
others = append(others, i)
n := len(supply)
start := anchor
if start < 0 || start >= n {
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]) })
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))]
return out
}
// 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),
// refinery (bought a moon of refinery.unlockGrade or higher), advisors (bought a tool);
// - 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);
// - 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",
@@ -101,14 +102,14 @@ func unlockClaim(ctx context.Context, tx pgx.Tx, userID, resource string) error
return unlock(ctx, tx, userID, "market", "claimed:"+resource)
}
// unlockRefineryAt opens the refinery when the player buys a moon of refinery.unlockGrade
// or higher.
// unlockRefineryAt records the grade of a moon bought (grade:<G>, for recipes with an
// unlockGrade: recipeOpen) and opens the refinery at refinery.unlockGrade or higher.
func unlockRefineryAt(ctx context.Context, tx pgx.Tx, userID, grade string) error {
c := cfg()
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.
+23 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"math"
"math/rand"
"slices"
"testing"
"time"
)
@@ -52,7 +53,7 @@ func TestOverdrillSingle(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)
stakes := []Stake{odStake(1, "w0", 12, 3)}
s := &State{Stakes: stakes}
@@ -148,3 +149,24 @@ func TestWideTieOrder(t *testing.T) {
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"
"fmt"
"math"
"slices"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -91,6 +93,24 @@ type RefinedItem struct {
Seconds int `json:"seconds"`
Inputs map[string]float64 `json:"inputs"`
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 {
@@ -115,6 +135,11 @@ func (c *GameConfig) unitPrice(name string) float64 {
func (cfg *GameConfig) validateRefinery() error {
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 {
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")
}
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)
if err != nil {
return err
@@ -709,6 +741,9 @@ func (s *State) refineryPage() *RefineryPage {
}
for i := range c.Refinery.Items {
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)}
for _, in := range card.Inputs {
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 {
return err
}
// A busy tool's idle copy is sold instead (see pickTool).
toolID, err := pickTool(ctx, tx, userID, toolID, false)
// (that tool: a busy one is refused below)
err := lockTool(ctx, tx, userID, toolID)
if err != nil {
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
// upgradeTool raises a tool's track of the kind (yield or cycle) a level (an idle copy of
// it, if there is one: see pickTool) and returns the tool it upgraded.
// upgradeTool raises the tool's track of the kind (yield or cycle) a level (that tool, also
// while it mines: from its next drill) and returns it.
func upgradeTool(ctx context.Context, db *pgxpool.Pool, userID, toolID, kind string) (string, error) {
if kind != "yield" && kind != "cycle" {
return "", gameErr("Pick an upgrade track")
}
err := inTx(ctx, db, func(tx pgx.Tx) 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
}
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") {
v.Drill = ""
}
v.Drill = s.idleCopy(v.Drill)
if v.Section != "advisors" || s.hire(v.Adv) == nil {
v.Adv = ""
}
@@ -890,9 +889,12 @@ func (s *State) page(v View) *Page {
sr := s.stakeRow(*st)
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 = groupCopies(s, p.Equipment)
for _, row := range p.Equipment {
if row.Selected {
p.Drill = s.drillPanel(row)
@@ -954,21 +956,6 @@ func (s *State) copyKey(t Tool) string {
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
// used an idle copy (pickTool) of the one asked for.
func followTool(selected, asked, used string) string {
@@ -978,42 +965,6 @@ func followTool(selected, asked, used string) string {
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.
func (p *Page) StakeURL(st *StakeRow) string {
return View{Section: "moons", Moon: st.MoonID, Well: st.WellID}.URL()