Compare commits

...
5 Commits
Author SHA1 Message Date
JosephFeranoandClaude Opus 5.5 04dee47571 Balance: progression pass: each drill takes about 4-5 claims per well on its grade and rips the grade below (yields PD-1 8, PX-4 60, L100 300, L300 4000, LRE 48000, EDE 110000, LCE 200000); drills priced so each tier earns a bit more per credit; upgrade levels repriced against copies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:34:20 +07:00
JosephFeranoandClaude Opus 5.5 d0716bf89a Merge main (per-drill upgrade tracks) into drill-cards
Keeps the well cards and the status-only aside; names follow the tracks model (code and
levels tag, e.g. "PD-1 Y2 C1"), no tiers or attachment tags on cards, tray or pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:28:21 +07:00
JosephFeranoandClaude Opus 5.5 c278865a24 Moons: well cards with every action in the main panel; the aside only shows
Each well is a card: supply and bar, a deploy slot (sends the drill picked in the new
tray of idle drills, else the smallest one-well drill with room), a slot per drill (time
and bar; when done, gold with Claim and Redeploy on it), and an idle slot with Redrill.
Multi-well drills get a card across the grid; Sell moon moves to the foot of the main
panel. The aside keeps the viewport and shows the well's or the moon's facts, no buttons;
picking a well no longer scrolls the narrow layout to it. Scenario wellcards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:21:35 +07:00
JosephFeranoandClaude Opus 5.5 b76a189d22 Client: Equipment cards and the drill panel show the two upgrade tracks (value and next value, Upgrade or Max level, a kept need line) and each track's ladder; attachments, slots, spares and tiers removed; prediction groups copies by track levels
Scenario tracks.py (click_check on both upgrade buttons); fixture, drillonboard, sellask,
texts and others updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:59:18 +07:00
JosephFeranoandClaude Opus 5.5 069bf39ad2 Drill upgrade tracks replace attachments and the tier ladder: each drill has a yield track and a cycle track of its own (flat values, 4-5 levels, refined parts from level 3, per tool); upgrade takes track=yield|cycle
Migration 020: a tool's tier N becomes cycle level N, attachments are refunded at their
price (logged refund_attachments) and dropped, upgrade_item is dropped, stake cycles are
kept to a tenth of a second. Attachments are gone from the config, actions, Store, drill
panel, cards, onboarding, sims, economy and players pages and the Preact client. Redrill
picks drills by their track yield. Prices calibrated with the sim: a first level is ~1.03x
a copy per credit; boosts player C 13m18, B 23m39, A 1h05m50, S 3h01m59 (was 13m15,
22m26, 1h08m39, 3h00m21).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:59:18 +07:00
76 changed files with 2559 additions and 3120 deletions

No files matched your search

+2 -2
View File
@@ -8,8 +8,8 @@ and stake them on wells. When a stake finishes (drill cycles are seconds:
`completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined
items (Refinery section: timed batches in slots; refined items sell for more and are parts
for higher tool upgrades). Drill attachments (bought in the Store, equipped on a drill from
its panel in Equipment) give it a modifier that makes it faster or bigger; advisors
for the higher upgrades). Each drill has two upgrade tracks of its own (Equipment: one raises
its claim per cycle, the other shortens its cycle, a level at a time, per tool); advisors
(Advisors section, one per seat) boost drill speed, yield, sell prices or the refinery for
the whole account. There is also a leaderboard (Leaderboard section: top miners by credits,
banned players left out).
+1 -1
View File
@@ -3,7 +3,7 @@
A native client for Moon Miners (rules and state live in the Go server: `../server`,
`../server/AGENTS.md`; the repo: `../AGENTS.md`). Same layout and HUD look as the web build
(header, moon list left, the section in the middle, aside with the moon viewport and the
selected well), made game-native. Target 16:10 at 1280x800 (Steam Deck); bigger windows
selected well; on Moons the aside only shows, every action is on the well cards), made game-native. Target 16:10 at 1280x800 (Steam Deck); bigger windows
show the UI at native pixel size with more room, smaller ones rearrange. Mouse now,
gamepad later. Details live in each file's header comment; this file has the rules and a
map.
+2 -2
View File
@@ -1,7 +1,7 @@
-- Chip / badge (the web's .badge, tier chips, attachment tags): a 1 px outlined box with
-- Chip / badge (the web's .badge, level chips, advisor tags): a 1 px outlined box with
-- a short label, optionally filled. Returns the box node and its width.
--
-- chip.new(parent, "T2/5", x, y, { color = "line_hi", text = "text_2", font = "mono" })
-- chip.new(parent, "Y2 C1", x, y, { color = "line_hi", text = "text_2", font = "mono" })
-- chip.new(parent, 3, x, y, { color = "ready", font = "mono", px = 12, min_w = 22 })
-- chip.new(parent, "Turbo core", x, y, { color = "violet", text = "violet_hi", upper = true })
--
+4 -4
View File
@@ -40,17 +40,17 @@ local GOLD_HI = C.ready_hi
local LOSS = C.loss
local FLASH = C.flash
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true, ["buy-attachment"] = true,
equip = true, hire = true, ["upgrade-batch"] = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true,
hire = true, ["upgrade-batch"] = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
-- Sounds per action kind (fx.js); claim and sell sound as their numbers arrive.
local SOUND = {
["buy-tool"] = "buy", ["buy-attachment"] = "buy", equip = "upgrade", unequip = "toggle",
["buy-tool"] = "buy",
hire = "confirm", ["seat-advisor"] = "upgrade", ["unseat-advisor"] = "toggle", ["swap-advisors"] = "toggle",
["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade", ["upgrade-batch"] = "upgrade",
["buy-moon"] = "moon", ["sell-moon"] = "shimmer", stake = "deploy", redrill = "deploy",
refine = "refine", collect = "collect", error = "error",
-- (sell-tool and sell-attachment: the inline confirm's (cards.ask_button), then the cash as the coins land)
-- (sell-tool: the inline confirm's (cards.ask_button), then the cash as the coins land)
}
local function v3(x, y)
+15 -9
View File
@@ -53,6 +53,12 @@ function M.copy(st)
return copy(st, true)
end
-- Copies of a tool: the same store item and track levels (tools, well.idle groups).
local function same_copy(a, b)
return a.storeItemId == b.storeItemId and (a.yieldLevel or 0) == (b.yieldLevel or 0)
and (a.cycleLevel or 0) == (b.cycleLevel or 0)
end
local function find(list, id, key)
key = key or "id"
for i, x in ipairs(list or {}) do
@@ -168,7 +174,7 @@ function M.tool(st, id)
return find(st.tools, id)
end
-- The tool to deploy for id: it, or an idle copy of it (same store item and tier).
-- The tool to deploy for id: it, or an idle copy of it (same store item and track levels).
function M.idle_copy(st, id)
local t = find(st.tools, id)
if not t then
@@ -178,7 +184,7 @@ function M.idle_copy(st, id)
return t
end
for _, x in ipairs(st.tools) do
if not x.stakeId and x.storeItemId == t.storeItemId and x.tier == t.tier then
if not x.stakeId and same_copy(x, t) then
return x
end
end
@@ -366,11 +372,11 @@ function M.add_stake(st, tool, moon_id, well, ctx, group)
-- Off the selected well's idle list.
if st.well then
for i, g in ipairs(st.well.idle or {}) do
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
if same_copy(g, tool) then
g.count = g.count - 1
if g.toolId == tool.id then
for _, x in ipairs(st.tools) do
if not x.stakeId and x.storeItemId == g.storeItemId and x.tier == g.tier then
if not x.stakeId and same_copy(x, g) then
g.toolId = x.id
break
end
@@ -414,7 +420,7 @@ function M.remove_stake(st, s)
if st.well and st.well.idle then
local g = nil
for _, x in ipairs(st.well.idle) do
if x.storeItemId == tool.storeItemId and x.tier == tool.tier then
if same_copy(x, tool) then
g = x
end
end
@@ -424,9 +430,9 @@ function M.remove_stake(st, s)
local free = M.supply(st, st.well.id)
local y = tool.yield * yield_boost(st, st.well.resource)
st.well.idle[#st.well.idle + 1] = {
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, tier = tool.tier, count = 1,
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, yieldLevel = tool.yieldLevel, cycleLevel = tool.cycleLevel, count = 1,
cycleSecs = tool.cycleSecs, yield = y, reach = tool.reach, here = math.min(y, free), free = free,
short = free < y, attachments = tool.attachments or {},
short = free < y,
}
end
end
@@ -667,7 +673,7 @@ M.register("stake", function(st, p, ctx)
local group = nil
if st.well and st.well.id == w.id then
for _, g in ipairs(st.well.idle or {}) do
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
if same_copy(g, tool) then
group = g
end
end
@@ -705,7 +711,7 @@ M.register("redrill", function(st, p, ctx)
local function group_of(t)
if st.well and st.well.id == id then
for _, g in ipairs(st.well.idle or {}) do
if g.storeItemId == t.storeItemId and g.tier == t.tier then
if same_copy(g, t) then
return g
end
end
+1 -1
View File
@@ -49,7 +49,7 @@ M.palettes = {
loss = { "#ff8a96" },
teal = { "#2dd4bf" },
violet = { "#b69cff" },
violet_hi = { "#dccfff" }, -- attachment and advisor effects
violet_hi = { "#dccfff" }, -- advisor effects
stat_up = { "#c4a8ff" }, -- a tool stat its next upgrade raises (not money)
up = { "#3ee6a0" },
flash = { "#ffffff" }, -- peak of a gain flash or a scan line
-4
View File
@@ -35,17 +35,13 @@ local KIND = {
redrill = { "cyan", "▼" },
upgrade = { "cyan", "▲" },
["buy-tool"] = { "cyan", "+" },
["buy-attachment"] = { "cyan", "+" },
["buy-seat"] = { "cyan", "+" },
["buy-slot"] = { "cyan", "+" },
equip = { "cyan", "▲" },
unequip = { "muted", "▼" },
hire = { "violet", "●" },
refine = { "violet", "◆" },
collect = { "teal", "◆" },
["sell-moon"] = { "ready", "₥" },
["sell-tool"] = { "ready", "₥" },
["sell-attachment"] = { "ready", "₥" },
["save-code"] = { "violet", "★" },
restore = { "violet", "★" },
error = { "danger", "!" },
+16 -1
View File
@@ -333,8 +333,13 @@ function M.dur(ms)
return string.format("%d:%02d", m, sec)
end
-- A drill cycle like the web's secs: "4s", "2m", "2m 30s".
-- A drill cycle like the web's secs: "4s", "2.6s" (tenths), "2m", "2m 30s".
function M.cycle(secs)
if secs < 60 and secs ~= math.floor(secs) then
local t = math.floor(secs * 10 + 0.5) / 10
return (t == math.floor(t) and string.format("%d", t) or string.format("%.1f", t)) .. "s"
end
secs = math.floor(secs + 0.5)
if secs < 60 then
return secs .. "s"
elseif secs % 60 == 0 then
@@ -343,6 +348,16 @@ function M.cycle(secs)
return string.format("%dm %ds", math.floor(secs / 60), secs % 60)
end
-- A tool's track levels after its name, like the web's Levels.Tag: " Y2 C1", "" for a
-- base tool (both levels 0). t: anything with yieldLevel and cycleLevel.
function M.levels_tag(t)
local y, c = t and t.yieldLevel or 0, t and t.cycleLevel or 0
if y == 0 and c == 0 then
return ""
end
return " Y" .. y .. " C" .. c
end
function M.plural(n, word)
return n .. " " .. word .. (n == 1 and "" or "s")
end
+68 -282
View File
@@ -1,26 +1,22 @@
-- The aside (region, right column): the moon viewport (the selected moon in every
-- section, the shroud on New moon, a grid when there is none) and under it the selected
-- well's panel (supply, claim, redrill, deploy cards, drills), or what the section puts
-- there (a section module's aside / aside_below, see core/main.lua), always under the
-- viewport.
-- 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).
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")
local tooltip = require("modules.tooltip")
local row_c = require("components.row")
local button = require("components.button")
local chip = require("components.chip")
local anim = require("modules.anim")
local images = require("modules.images")
local cards = require("screens.game.cards")
local ready = require("screens.game.core.ready")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
@@ -94,45 +90,8 @@ local function reach_short(st, n)
return pick and pick.short ~= "" and pick.short or (n .. " wells")
end
-- The long wording of a reach (catalog.reach label): "Mines 3 wells", "Strips the whole
-- moon"; "" for one.
local function reach_label(st, n)
local list = st.catalog and st.catalog.reach or {}
local pick = list[#list]
for _, r in ipairs(list) do
if r.reach == n then
pick = r
end
end
return pick and pick.label or ""
end
-- An attachment's effect ("+20% speed", catalog), for its tag's tooltip.
local function effect_of(st, name)
for _, a in ipairs(st.catalog and st.catalog.attachments or {}) do
if a.name == name then
return a.effectText
end
end
end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- The width of a text in a style (a probe).
local function text_w(c, str, style)
local t = ui.text(c, str, style, 0, 0)
local w = ui.text_width(t)
gui.delete_node(t)
return w
end
-- A text link (H.text_link) as a box h tall.
local function link_box(g, c, H, str, style, h, v, id)
return made({ width = text_w(c, str, style), height = h, shrink = 0 }, function(p, x, y)
H.text_link(g, p, str, style, x, y + h / 2, v, id)
end)
end
-- A stat line (H.fact: cap label, value at the right; 20 tall).
local function fact(H, label, value, opts)
return made({ height = 20 }, function(p, x, y, w)
@@ -145,115 +104,10 @@ local M11 = { font = "mono", px = 11, color = "muted" }
local M12 = { font = "mono", px = 12, color = "muted" }
local S12 = { font = "sans", px = 12, color = "muted" }
-- Deploy cards: one per idle tool group (well.idle), in a grid (app.css .deploy-cards:
-- 170 px columns, 8 apart). A card is the tool's picture on the dark media strip (3:2,
-- count and attachment pips over it), one line of name and tier, one of what a cycle gets
-- and Deploy, and a line kept for the short note: every card is the same height, so a
-- deploy never moves the cards after it (only a used-up group's card goes; the next one
-- slides into its spot). A tool that would get less than its yield here has an amber
-- edge; one that would get nothing (the well is spoken for) is also dimmed to .6 (.85
-- under the mouse) by a veil in the panel's color (.is-short / .is-spoken). The card is a
-- button built at its width; its content is a tree solved inside it.
local DEPLOY_MIN, DEPLOY_GAP = 170, 8
local function deploy_media_h(cw)
return math.floor(cw * 2 / 3)
end
local function deploy_card_h(cw)
return deploy_media_h(cw) + 1 + 7 + 16 + 3 + 16 + 3 + 14 + 8
end
local function deploy_card(g, c, well, grp, H, cw)
local hgt = deploy_card_h(cw)
local mh = deploy_media_h(cw)
local spoken = grp.short and grp.here <= 0
local edge = grp.short and { "ready", spoken and 0.35 or 0.45 } or nil
return made({ height = hgt }, function(p, x, y, inner)
-- blocked: the drills here take all it has left; the server refuses the stake.
local b, bn = ui.button(p, "", x, y, { w = inner, small = true, edge = edge, disabled = grp.blocked })
gui.set_size(b, vec(inner, hgt))
gui.set_size(ui.part(bn, "button", "edge"), vec(inner, hgt))
local tool = find(g.state.tools, grp.toolId)
local n_att = #(grp.attachments or {})
local gets_s = "+" .. ui.money(grp.here) .. " / " .. ui.cycle(grp.cycleSecs)
local reach = (grp.reach or 1) > 1 and fg.label(b, "· " .. reach_short(g.state, grp.reach), S12, 16, { fit = true, shrink = 1 }) or nil
if reach then
local label = reach_label(g.state, grp.reach)
if label ~= "" then
reach.done = function(n) tooltip.add(n, label) end
end
end
local gets = fg.label(b, gets_s, M12, 16, { shrink = 0 })
gets.done = function(n)
tooltip.add(n, "What a cycle gets here after the drills already on it"
.. (grp.short and (" (its yield is " .. ui.money(grp.yield) .. ")") or ""))
end
local note = grp.short and fg.label(b, grp.here > 0 and ("Only " .. ui.money(grp.free) .. " left after current drills")
or "Well spoken for: nothing left", { font = "sans", px = 11, color = "ready" }, 14, { fit = true, shrink = 1 }) or space(0, 14)
local count = grp.count > 1 and ("×" .. grp.count) or nil
local count_w = count and text_w(b, count, { font = "mono", px = 12, color = "text" }) or 0
local tree = col({ width = inner, height = hgt }, {
-- The picture (a square, centered; grey when spoken for) on the media strip.
made({ height = mh, shrink = 0 }, function(q, ix, iy, iw)
ui.rect(q, ix + 1, iy + 1, iw - 2, mh - 1, "bg")
local img = ui.rect(q, ix + math.floor((iw - mh) / 2), iy + 1, mh - 1, mh - 1, "none")
if tool and tool.image then
images.set(img, tool.image)
if spoken then
-- Greyed (no grayscale filter here): a neutral grey over the picture.
ui.rect(q, ix + math.floor((iw - mh) / 2), iy + 1, mh - 1, mh - 1, "off", 0.6)
end
end
if n_att > 0 then
ui.rect(q, ix + 6, iy + 6, n_att * 13 + 4, 18, "panel", 0.82)
cards.pips(q, n_att, n_att, ix + 8, iy + 15)
end
if count then
ui.rect(q, ix + iw - 6 - count_w - 10, iy + 6, count_w + 10, 20, "panel", 0.82)
ui.text(q, count, { font = "mono", px = 12, color = "text" }, ix + iw - 6 - count_w - 5, iy + 16)
end
end),
made({ height = 1, shrink = 0 }, function(q, ix, iy, iw) ui.rect(q, ix + 1, iy, iw - 2, 1, "line") end),
col({ padding = { 7, 9, 8, 9 }, gap = 3, grow = 1 }, {
row({ height = 16, align = "center" }, {
fg.label(b, grp.name, { font = "sans_semi", px = 13, color = "text" }, 16, { fit = true, shrink = 1 }),
space(6), fg.label(b, "T" .. grp.tier, { font = "mono", px = 11, color = "cyan" }, 16, { shrink = 0 }),
}),
row({ height = 16, align = "center" }, {
gets, reach and space(5) or nil, reach, fg.fill(),
space(5), fg.label(b, "Deploy", { font = "sans_semi", px = 11, color = "cyan", tracking = 0.06, upper = true }, 16, { shrink = 0 }),
}),
note,
}),
})
fg.build(flex.solve(tree, 0, 0), b)
local title = "Deploy " .. grp.name .. " T" .. grp.tier
for _, a in ipairs(grp.attachments or {}) do
title = title .. " · " .. (type(a) == "table" and a.name or tostring(a))
end
tooltip.add(b, title)
if not grp.blocked then
H.action(g, b, bn, "stake", { tool = grp.toolId, target = well.id })
end
if spoken then
local veil = ui.rect(b, 0, 0, inner, hgt, "veil", 0.4)
if not grp.blocked then
g.druid:new_hover(b, nil, function(_, on)
gui.cancel_animations(veil, "color.w")
local a = on and 0.15 or 0.4
if anim.reduced or button.instant then
anim.set(veil, "color.w", a)
else
gui.animate(veil, "color.w", a, gui.EASING_OUTQUAD, 0.15)
end
end)
end
end
end)
end
-- A drill on the well (34 tall; it slides in when the drill was just deployed):
-- thumbnail, name, tier and the attachments' tags; 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 name, tier = moons.tool_name(st, d.toolId)
local name, tag = moons.tool_name(st, d.toolId)
local tool = find(st.tools, d.toolId)
return made({ height = 34 }, function(p, x, y, inner)
local dr = ui.rect(p, x, y, inner, 34, "none")
@@ -268,29 +122,11 @@ local function drill_line(g, c, st, d, H, enters)
end
local x0 = 36
local nt = ui.text(dr, name, CARD_NAME, x0, 8)
local tt = ui.text(dr, "T" .. tier, M11, x0 + ui.text_width(nt) + 6, 8)
-- Tags while they fit (and leave 50 for the time).
local head = { fg.node_label(nt, 16), space(6), fg.node_label(tt, 16), space(8) }
local tx = x0 + ui.text_width(nt) + 6 + ui.text_width(tt) + 8
for _, a in ipairs(tool and tool.attachments or {}) do
if tx + 60 > inner - 50 then
break
end
local probe = ui.rect(dr, 0, 0, 1, 1, "none")
local aw = cards.tag(probe, a, 0, 0)
gui.delete_node(probe)
if tx + aw > inner - 50 then
break
end
head[#head + 1] = made({ width = aw, height = 16, shrink = 0 }, function(q, ax, ay)
cards.tag(q, a, ax, ay + 8)
local fx_text = effect_of(st, a)
if fx_text then
tooltip.add(ui.rect(q, ax, ay, aw, 16, "none"), fx_text)
end
end)
head[#head + 1] = space(4)
tx = tx + aw + 4
local head = { fg.node_label(nt, 16) }
if tag ~= "" then
local tt = ui.text(dr, tag:sub(2), M11, x0 + ui.text_width(nt) + 6, 8)
head[#head + 1] = space(6)
head[#head + 1] = fg.node_label(tt, 16)
end
-- The amount it gets in ready when it comes up short (engine markup runs).
local reach = (d.reach or 1) > 1 and (" · " .. ((d.reachText or "") ~= "" and d.reachText or reach_short(st, d.reach))) or ""
@@ -335,11 +171,6 @@ local function drill_line(g, c, st, d, H, enters)
end)
end
-- A row of claim buttons (Claim / its countdown, and Claim and redeploy beside it).
local function claim_row(kids)
return row({ height = S.button_h, gap = 8 }, kids)
end
-- The selected well under the viewport (one tree; it scrolls); returns its bottom.
local function well_panel(g, root, pad, top, inner, H)
local st = g.state
@@ -376,8 +207,7 @@ local function well_panel(g, root, pad, top, inner, H)
-- The well note in the server's words (the web's): the lead (over-drilled, fully
-- committed) in the warning color, then what the drills take of it, amber once
-- they want it all; one paragraph (engine markup runs). Its two lines are kept
-- when there is no note, and "After current drills" is always there: the first
-- deploy must not push the deploy cards down (buttons never move under the pointer).
-- when there is no note, so a deploy doesn't make the facts jump.
local note = nil
if (wrow.noteText or "") ~= "" then
local runs = {}
@@ -427,50 +257,6 @@ local function well_panel(g, root, pad, top, inner, H)
end
end
-- The web's order: deploy, drills, then the claims and Redrill.
if not dry then
add(fg.label(c, "Deploy a tool", "h3", 12))
add(space(0, 6))
if #w.idle > 0 then
local cards_ = {}
local _, cw = flex.columns(inner, DEPLOY_MIN, DEPLOY_GAP)
for _, grp in ipairs(w.idle) do
cards_[#cards_ + 1] = deploy_card(g, c, w, grp, H, cw)
end
add(flex.grid(inner, DEPLOY_MIN, DEPLOY_GAP, cards_))
add(space(0, 6))
local better = w.better
if better then
-- "Better: <resource> well on <moon>, X free": the well is a link.
local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
local rest = (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free"
local name, tier = moons.tool_name(st, better.toolId)
add(row({ height = 40, align = "start" }, {
row({ height = S.button_sm_h, grow = 1, shrink = 1, align = "center", padding_right = 8 }, {
fg.label(c, "Better:", "small", 16), space(4),
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, better.resource) end),
space(4),
link_box(g, c, H, better.resource .. " well", "small", 16,
{ section = "moons", moon = better.moonId, well = better.wellId }, "better-well"),
fg.label(c, rest, "small", 16, { fit = not ui.needs_rich(rest), shrink = 1 }),
}),
fg.button(c, "Deploy there", { small = true, primary = true }, function(b, bn)
H.action(g, b, bn, "stake", { tool = better.toolId, target = better.wellId, s = "moons", moon = better.moonId, well = better.wellId })
tooltip.add(b, "Deploy " .. name .. " T" .. tier .. " on that well")
end),
}))
end
else
add(row({ height = 16, align = "center" }, {
fg.label(c, "No idle tools.", "small", 16),
st.unlocked.store and space(5) or nil,
st.unlocked.store and link_box(g, c, H, "Buy one in the store", { font = "sans", px = 13 }, 16, { section = "store", moon = g.view.moon }) or nil,
st.unlocked.store and fg.label(c, ".", "small", 16) or nil,
}))
add(space(0, 6))
end
end
-- Drills on this well, its own and multi-well ones, oldest first (the web's Queue).
local drills = {}
for _, d in ipairs(wrow.drills) do
@@ -499,62 +285,22 @@ local function well_panel(g, root, pad, top, inner, H)
add(col({ gap = 2 }, lines))
add(space(0, #lines > 0 and 10 or 8))
-- Claim (ready), Claim countdown (mining), Claim and redeploy; the multi-well claim;
-- Redrill. Amounts move on as more drills finish on our clock (core/ready).
local claims = {}
local amount = ready.amount(g, wrow, wrow.supply)
if wrow.ready > 0 then
claims[#claims + 1] = claim_row({
fg.button(c, "Claim " .. ui.money(amount), { primary = true }, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-well"] = w.id })
end),
(not dry and st.unlocked.redeploy) and fg.button(c, "Claim and redeploy", {}, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-well"] = w.id, redeploy = "1" })
end) or nil,
})
elseif wrow.nextEnd then
claims[#claims + 1] = made({ height = S.button_h }, function(p, x, y)
H.claim_wait(g, p, x, y, wrow.nextEnd, { key = "wait:well", params = { ["claim-well"] = w.id }, moon = moon.id,
done = amount > 0 and ("Claim " .. ui.money(amount)) or nil,
also = not dry and st.unlocked.redeploy
and { label = "Claim and redeploy", params = { ["claim-well"] = w.id, redeploy = "1" } } or nil })
end)
-- Idle drills that last mined it (Redrill on its card), and a better well for the
-- idle drills (the server's pick), as text.
if (wrow.redrill or 0) > 0 then
add(fact(H, "Idle, last mined here", tostring(wrow.redrill)))
add(space(0, 6))
end
if #wide_here > 0 then
local wamount = ready.amount(g, wide, wide.cap)
if wide.ready > 0 then
claims[#claims + 1] = claim_row({
fg.button(c, "Claim " .. ui.money(wamount), { primary = true }, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id })
end),
st.unlocked.redeploy and fg.button(c, "Claim and redeploy", {}, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id, redeploy = "1" })
end) or nil,
})
elseif wide.nextEnd then
claims[#claims + 1] = made({ height = S.button_h }, function(p, x, y)
H.claim_wait(g, p, x, y, wide.nextEnd, { params = { ["claim-wide"] = moon.id }, moon = moon.id,
done = wamount > 0 and ("Claim " .. ui.money(wamount)) or nil })
end)
end
end
if (wrow.redrill or 0) > 0 and not dry and st.unlocked.redrill then
claims[#claims + 1] = claim_row({
fg.button(c, "Redrill" .. (wrow.redrill > 1 and (" " .. wrow.redrill) or ""), { variant = "redrill" }, function(b, bn)
H.action(g, b, bn, "redrill", { ["redrill-well"] = w.id })
end),
})
end
add(col({ gap = 10, padding_bottom = #claims > 0 and 10 or 0 }, claims))
-- Selling the moon (once the store is open), at the foot of the moon's pane.
if st.unlocked.store and moon.sale then
add(space(0, 14))
add(made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end))
add(space(0, 12))
add(moon_sale.box(g, H, c, moon, inner))
local better = w.better
if better and not dry then
local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
local name, tag = moons.tool_name(st, better.toolId)
add(fg.text_para(c, "Better for " .. name .. tag .. ": the " .. better.resource .. " well"
.. (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free.", "small", { max_width = math.min(inner, S.text_max) }))
add(space(0, 6))
end
-- (no buttons here: every action on the well is on its card in the main panel)
local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, pad, top), c)
for _, f in ipairs(enters) do
@@ -590,6 +336,45 @@ function M.row_viewport(w)
return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2)))
end
-- The selected moon's facts under the viewport when no well is picked (read only).
local function moon_status(g, c, x, y, inner, H)
local st = g.state
local moon = st.moon
local supply, dry, mining, done = 0, 0, 0, 0
for _, w in ipairs(moon.wells or {}) do
supply = supply + w.supply
dry = dry + (w.supply <= 0 and 1 or 0)
end
local now = H.now(g)
for _, s in ipairs(st.stakes or {}) do
if s.moonId == moon.id then
if s.ready or (s["end"] or math.huge) <= now then
done = done + 1
else
mining = mining + 1
end
end
end
local idle = 0
for _, t in ipairs(st.tools or {}) do
idle = idle + (t.stakeId and 0 or 1)
end
local kids = {
fg.label(c, moon.name, "h2", 18), space(0, 10),
fg.text_para(c, "Click a well's card for its details.", "small", { max_width = math.min(inner, S.text_max) }),
space(0, 10),
fact(H, "Wells", tostring(#(moon.wells or {})) .. (dry > 0 and (" · " .. dry .. " tapped out") or "")),
fact(H, "Supply left", ui.money(supply)),
fact(H, "Drills mining", tostring(mining)),
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 })
end
fg.build(flex.solve(col({ width = inner }, kids), x, y), c)
end
-- A heading (h2) and a line under the viewport.
local function caption(c, x, y, inner, title, text)
fg.build(flex.solve(col({ width = inner }, {
@@ -645,13 +430,14 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
gui.delete_node(box)
end
if moon and st.well then
-- The well panel scrolls under the viewport (many deploy cards or drills).
-- The well panel scrolls under the viewport (many drills).
local top = cy - 8
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
elseif moon and moon.finished then
caption(root, pad, cy, inner, moon.name, "Mined out. Nothing left to dig.")
elseif moon then
caption(root, pad, cy, inner, moon.name, moon.finished and "Mined out. Nothing left to dig."
or "Pick a well in the table to deploy tools and claim resources.")
moon_status(g, root, pad, cy, inner, H)
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 -22
View File
@@ -48,14 +48,13 @@ function M.pct(m)
end
local EFFECT_WORDS = {
attachment = { { "speed", "speed" }, { "yield", "yield" } },
advisor = { { "speed", "drill speed" }, { "yield", "yield" }, { "sell", "sell prices" }, { "refine", "refinery speed" } },
}
-- "+20% speed", "+5% drill speed, +5% yield" (kind: "attachment" or "advisor").
-- "+5% drill speed, +5% yield" (kind: "advisor").
function M.effect(effect, kind)
local parts = {}
for _, e in ipairs(EFFECT_WORDS[kind or "attachment"]) do
for _, e in ipairs(EFFECT_WORDS[kind or "advisor"]) do
local m = effect and effect[e[1]] or 1
if m > 1 then
parts[#parts + 1] = M.pct(m) .. " " .. e[2]
@@ -187,29 +186,12 @@ function M.stat_up(parent, label, from, to, x, y)
return w
end
-- A violet tag (attachment names); returns its width.
-- A violet tag (advisor labels); returns its width and the box.
function M.tag(parent, text, x, y, color)
local box, w = chip.new(parent, text, x, y, { color = color or "violet", alpha = 0.5, text = color or "violet_hi", upper = true })
return w, box
end
-- Attachment slot pips: filled diamonds for used slots. Returns the width.
function M.pips(parent, used, slots, x, y)
for i = 1, slots do
local p = ui.rect(parent, x + (i - 1) * 13 + 5, y, 8, 8, "violet", i <= used and 1 or 0)
gui.set_pivot(p, gui.PIVOT_CENTER)
gui.set_position(p, vec(x + (i - 1) * 13 + 5, -y))
gui.set_rotation(p, vmath.vector3(0, 0, 45))
if i > used then
local o = ui.outline(parent, 0, 0, 8, 8, "violet")
gui.set_pivot(o, gui.PIVOT_CENTER)
gui.set_position(o, vec(x + (i - 1) * 13 + 5, -y))
gui.set_rotation(o, vmath.vector3(0, 0, 45))
end
end
return slots * 13
end
-- Refined parts an upgrade needs: "● 3/5 Dusk Alloy" chips, red when short. Returns the
-- height used.
function M.parts(st, parent, parts, x, y, w)
@@ -282,7 +264,7 @@ end
---------------------------------------------------------------- inline confirm
-- Sells ask in place, not in a dialog (sell-tool, sell-attachment, sell-moon): the first
-- Sells ask in place, not in a dialog (sell-tool, sell-moon): the first
-- click on the button (M.ask_button) puts it in its asking state, which its section builds
-- instead of the button in the same footprint (M.asking, M.ask_buttons, M.ask_text): the
-- button's spot becomes Cancel, Confirm (the server's OK words, "Sell · ₥750") sits just
+4 -3
View File
@@ -251,7 +251,7 @@ local function build_main(g, parent, x, y, w, h)
local st = g.state
local section = g.view.section
local common = local_sig(g, MAIN_READY[section] or {})
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, g.side_w, g.view, g.advisor_pick, g.refine_qty, g.sale_open, g.nm_grade,
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, g.side_w, g.view, g.advisor_pick, g.refine_qty, g.sale_open, g.nm_grade, g.deploy_pick or false,
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}))
blocks.keep(g, "main", sig, parent, function(holder)
for _, m in pairs(M.SECTIONS) do
@@ -446,10 +446,11 @@ function M.build(g)
moon_view.move(g.viewport_node) -- (where the page's offset put it)
g.viewport_rect = g.fx:rect_of(g.viewport_node)
end
-- Picking a well, a drill or an advisor brings the aside into view.
-- Picking a drill or an advisor brings the aside into view (not a well: its card,
-- where its actions are, stays in view; the aside only shows its facts).
local v = g.view
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick) }, "|")
local want = (v.well or "") ~= "" or (v.drill or "") ~= "" or v.section == "advisors"
local want = (v.drill or "") ~= "" or v.section == "advisors"
if s and want and key ~= g.page_key then
s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0))
end
+169 -239
View File
@@ -1,11 +1,12 @@
-- The Equipment section (state.equipment): a card per tool (copies of the same type, tier
-- and attachments share one) with its image, tier, status, stats and what the next
-- upgrade gives (stat_up violet, apart from money), attachment pips and tags, and the
-- upgrade button with its parts. A card opens the drill panel (view drill=<tool id>) in
-- the aside: stats, upgrade, tier ladder, slots (unequip), spare attachments (equip, with
-- the stats they'd give; sell), 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
-- (attachments, upgrades, selling): the panel is stats, tiers and idle copies.
-- 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.
local theme = require("modules.theme")
local ui = require("modules.ui")
@@ -32,36 +33,22 @@ local function find(list, key, value)
end
end
local function catalog_attachment(st, name)
return find(st.catalog and st.catalog.attachments, "name", name) or { effect = {} }
end
local function drill_view(self, id)
return { section = "equipment", drill = id, moon = self.view.moon, well = self.view.well }
end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- Stats with the next upgrade's changes: Tier, Yield, Reach (when it grows), Cycle,
-- wrapping (rows 20 tall, 16 apart). A tree piece.
-- Stats: Yield and Cycle (a stake's now), wrapping (rows 20 tall, 16 apart). A tree piece.
local function stats_box(st, parent, t)
local u = t.preview
local items = {}
if u then
items[#items + 1] = { "Tier", "T" .. t.tier, "T" .. u.tier }
end
items[#items + 1] = { "Yield", ui.money(t.yield), u and u.yieldTo > u.yieldFrom + 1e-9 and ui.money(u.yieldTo) or nil }
if u and u.reachTo > u.reachFrom then
items[#items + 1] = { "Reach", tostring(u.reachFrom), cards.wells(st, u.reachTo) }
end
items[#items + 1] = { "Cycle", ui.cycle(t.cycleSecs), u and u.secsTo ~= u.secsFrom and ui.cycle(u.secsTo) or nil }
local items = { { "Yield", ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) } }
local kids = {}
for _, it in ipairs(items) do
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
local iw = cards.stat_up(probe, it[1], it[2], it[3], 0, 0)
local iw = cards.stat_up(probe, it[1], it[2], nil, 0, 0)
gui.delete_node(probe)
kids[#kids + 1] = made({ width = iw, height = 20, shrink = 0 }, function(p, x, y)
cards.stat_up(p, it[1], it[2], it[3], x, y + 8)
cards.stat_up(p, it[1], it[2], nil, x, y + 8)
end)
end
return row({ wrap = true, column_gap = 16 }, kids)
@@ -75,56 +62,95 @@ local function stats(st, parent, t, x, y, w)
return tree.h
end
-- The upgrade button (or "Max tier"), what's short and the parts. A tree piece w wide.
local function upgrade_box(self, H, st, parent, t, w, small)
local n = t.next
-- 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))
end
local TRACK_NAME = { font = "sans_semi", px = 12, color = "text_2", tracking = 0.04 }
local M12 = { font = "mono", px = 12, color = "muted" }
-- One upgrade track, a column w wide: name and level (16), value ▸ next (18), the button
-- (or Max level, the same size), the need line (18, kept: "Need ₥X more" or blank) and
-- the parts it uses. Every box has these rows, so both buttons of a card sit at the same
-- height and never move. A tree piece.
local function track_box(self, H, st, parent, t, tr, w)
local n = tr.next
local val = made({ height = 18, shrink = 0 }, function(p, x, y)
local now = ui.text(p, track_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, track_value(tr, n.value), { font = "mono_semi", px = 13, color = "stat_up" }, ax, y + 9)
tooltip.add(nx, "After the upgrade")
end
end)
local kids = {
row({ height = 16, align = "center" }, {
fg.label(parent, tr.name, TRACK_NAME, 16, { fit = true, shrink = 1, shrink_text = 0.85 }),
fg.fill(), space(6),
fg.label(parent, tr.level .. "/" .. tr.max, M12, 16, { shrink = 0 }),
}),
val,
space(0, 2),
}
-- The parts line keeps the room of the most any level of the track needs (measured at
-- this width), so the box (and the card) stays the same height from level to level.
local parts_h = 0
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
for _, r in ipairs(tr.ladder or {}) do
if #(r.parts or {}) > 0 then
parts_h = math.max(parts_h, cards.parts(st, probe, r.parts, 0, 0, w))
end
end
gui.delete_node(probe)
if not n then
return col({ width = w }, { fg.label(parent, "Max tier", "small", 20), space(0, 2) })
kids[#kids + 1] = fg.button(parent, "Max level", { small = true, disabled = true }, nil, { width = w })
kids[#kids + 1] = space(0, 18 + parts_h)
return col({ width = w }, kids)
end
local ok = n.canAfford
local kids = {
fg.button(parent, "Upgrade to T" .. n.tier, { small = small, price = n.price, disabled = not ok }, function(b, nodes)
if not ok then
tooltip.add(b, n.blockedText)
else
cards.pulse(nodes)
H.action(self, b, nodes, "upgrade", { tool = t.id })
end
end, { width = w }),
space(0, 4),
}
-- Credits short: the server's shortText under the button (parts short: the tooltip only).
local why = n.blocked ~= "" and (n.shortText or "") or ""
if why ~= "" then
local style = { font = "sans", px = 12, color = "danger" }
kids[#kids + 1] = fg.measured(parent, function(p, x, y, bw)
local probe = ui.rect(p, 0, 0, 1, 1, "none")
local lh = cards.para(probe, why, style, 0, 0, bw)
gui.delete_node(probe)
if lh <= 18 then
store.need_text(p, why, x + bw, y + 8)
else
cards.para(p, why, style, x, y, bw)
end
return math.max(18, lh + 2)
end)
elseif small then
-- A card keeps the need line's place (like the store's): the buttons in a row of
-- cards line up, and a card doesn't change size when credits come and go.
kids[#kids + 1] = space(0, 18)
end
if #(n.parts or {}) > 0 then
kids[#kids + 1] = space(0, 2)
kids[#kids + 1] = fg.measured(parent, function(p, x, y, bw)
return cards.parts(st, p, n.parts, x, y, bw)
end)
-- The label that fits the column: "Upgrade · ₥1,100", else "Up · ₥1,100".
local label = "Upgrade"
if fg.button_w(parent, label, { small = true, price = n.price }) > w then
label = "Up"
end
kids[#kids + 1] = fg.button(parent, label, { small = true, price = n.price, disabled = not ok }, function(b, nodes)
tooltip.add(b, n.label .. ((not ok and (n.blockedText or "") ~= "") and (": " .. n.blockedText) or ""))
if ok then
cards.pulse(nodes)
H.action(self, b, nodes, "upgrade", { tool = t.id, track = tr.kind })
end
end, { width = w })
-- Credits short: the server's shortText (right-aligned, like the store's); else blank.
local why = n.blocked == "credits" and (n.shortText or "") or ""
kids[#kids + 1] = made({ height = 18, shrink = 0 }, function(p, x, y, bw)
if why ~= "" then
store.need_text(p, why, x + bw, y + 9)
end
end)
kids[#kids + 1] = made({ height = parts_h, shrink = 0 }, function(p, x, y, bw)
cards.parts(st, p, n.parts, x, y, bw)
end)
return col({ width = w }, kids)
end
-- Both tracks side by side, w wide (8 apart). A tree piece.
local function tracks_box(self, H, st, parent, t, w)
local cw = math.floor((w - 8) / 2)
local kids = {}
for i, tr in ipairs(t.tracks or {}) do
if i > 1 then
kids[#kids + 1] = space(w - cw * 2)
end
kids[#kids + 1] = track_box(self, H, st, parent, t, tr, cw)
end
return row({ width = w, align = "start" }, kids)
end
-- The same at (x, y) (a card's footer); returns the height used.
local function upgrade(self, H, st, parent, t, x, y, w, small)
local tree = upgrade_box(self, H, st, parent, t, w, small)
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
end
@@ -169,7 +195,8 @@ end
local function tool_card(self, H, st, c, t, open)
c:image(t.image)
-- Name, ×count, tier chip on the right.
-- 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" }, {
@@ -177,13 +204,13 @@ local function tool_card(self, H, st, c, t, open)
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),
fg.inline(p, 16, function(q, x, y, probing)
local tier_chip, cwid = chip.new(q, "T" .. t.tier .. "/" .. t.maxTier, x, y, { font = "mono", px = 11, text = "text_2", pad = 5 })
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(tier_chip, "Tier")
tooltip.add(lv_chip, "Yield level " .. (t.yieldLevel or 0) .. ", cycle level " .. (t.cycleLevel or 0))
end
return cwid
end),
end) or nil,
}), 0, 0), p)
end)
c:block(t.reach > 1 and 22 or 28, function(p, bw)
@@ -212,29 +239,12 @@ local function tool_card(self, H, st, c, t, open)
H.navigate(self, drill_view(self, t.id))
end
if open then
-- Attachments: slot pips over the image's corner, tags under the stats.
local holder = ui.rect(c.root, 6, 6, 1, 22, "panel")
local used = t.slots - t.freeSlots
local pw = cards.pips(holder, used, t.slots, 6, 11)
gui.set_size(holder, vec(pw + 10, 22))
gui.set_color(holder, ui.color("bg", 0.82))
tooltip.add(holder, "Attachment slots")
if #(t.gear or {}) > 0 then
c:block(24, function(p)
local gx = 0
for _, g in ipairs(t.gear) do
local gw, box = cards.tag(p, g.name, gx, 9)
tooltip.add(box, g.effectText or cards.effect(g.effect, "attachment"))
gx = gx + gw + 6
end
end)
end
-- Measure the upgrade block on a probe; the footer sits at the row's bottom.
-- 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 = upgrade(self, { action = function() end }, st, probe, t, 0, 0, c.bw, true)
local fh = tracks(self, { action = function() end }, st, probe, t, 0, 0, c.bw)
gui.delete_node(probe)
c:footer(fh, function(f, bw)
upgrade(self, H, st, f, t, 0, 0, bw, true)
c:footer(fh + 2, function(f, bw)
tracks(self, H, st, f, t, 0, 2, bw)
end)
end
c.selected = t.selected and self.view.drill ~= ""
@@ -248,7 +258,7 @@ function M.build(self, root, px, py, w, h, H)
local c = area.content
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 " Pick one to equip attachments." or ""),
fg.label(c, ui.plural(#st.tools, "tool") .. "." .. (open and " Each drill has two upgrade tracks: more per cycle, or shorter cycles." or ""),
{ font = "sans", px = 15, color = "muted" }, 12),
space(0, 18),
open and fg.button(c, "Buy tools", { small = true }, function(b)
@@ -293,31 +303,49 @@ end
local TIER_HEAD = { font = "sans_semi", px = 10, color = "muted", tracking = 0.1, upper = true }
-- The tier ladder: a grid (Tier, Yield, Reach, Cycle at 0, 20, 42, 66 % of the row's
-- inner width, ₥/h at the right), the current tier and the next lit.
local function ladder(st, c, d, inner)
local fr = { 0, 0.2, 0.42, 0.66, 1 }
-- A track's ladder: a grid (Level, value, price and parts at 0, 16, 40 % of the row's
-- inner width, ₥/h at the right), the current level and the next lit.
local function ladder(st, c, tr, inner)
local fr = { 0, 0.16, 0.4, 0.84 }
local cols = {}
for i = 1, 4 do
for i = 1, 3 do
cols[i] = math.floor((inner - 16) * fr[i + 1]) - math.floor((inner - 16) * fr[i])
end
cols[5] = "1fr"
cols[4] = "1fr"
local heads = {}
for i, lbl in ipairs({ "Tier", "Yield", "Reach", "Cycle" }) do
for i, lbl in ipairs({ "Level", tr.kind == "cycle" and "Cycle" or "Yield", "Price" }) do
heads[i] = fg.label(c, lbl, TIER_HEAD, 12)
end
local head = box({ columns = cols, height = 14, padding = { 0, 8, 0, 8 }, align = "start" }, heads)
-- (₥/h: its left edge 22 before the rate column's right edge)
local rate_head = fg.label(c, theme.currency .. "/h", TIER_HEAD, 12, { position = "absolute", top = 0, left = inner - 30 })
local rows = {}
for _, r in ipairs(d.ladder) do
for _, r in ipairs(tr.ladder or {}) do
local color = r.current and "text" or (r.next and "stat_up" or "text_2")
local font = r.next and "mono_semi" or "mono"
local kids = {}
for i, v in ipairs({ "T" .. r.tier, ui.money(r.yield), cards.wells(st, r.reach), ui.cycle(r.cycleSecs) }) do
kids[i] = fg.label(c, v, { font = font, px = 12, color = color }, 20)
local price = "—"
local full
if (r.price or 0) > 0 then
price = theme.currency .. cards.compact(r.price)
local parts = {}
for _, pt in ipairs(r.parts or {}) do
parts[#parts + 1] = ui.money(pt.need) .. " " .. pt.name
end
if #parts > 0 then
price = price .. " + " .. table.concat(parts, ", ")
full = theme.currency .. ui.money(r.price) .. " + " .. table.concat(parts, ", ")
end
end
kids[5] = fg.label(c, cards.compact(r.rate), { font = font, px = 12, color = r.next and "stat_up" or "muted" }, 20, { justify_self = "end" })
local pl = fg.label(c, price, { font = font, px = 12, color = color }, 20, { fit = true, shrink = 1 })
if full then
pl.done = function(n) tooltip.add(n, full) end
end
local kids = {
fg.label(c, tostring(r.level), { font = font, px = 12, color = color }, 20),
fg.label(c, track_value(tr, r.value), { font = font, px = 12, color = color }, 20),
pl,
fg.label(c, cards.compact(r.rate), { font = font, px = 12, color = r.next and "stat_up" or "muted" }, 20, { justify_self = "end" }),
}
local lit = r.current and "cyan" or (r.next and "stat_up") or nil
rows[#rows + 1] = box({ columns = cols, rows = { 20 }, padding = { 0, 8, 0, 8 } }, kids, { make = lit and function(p, x, y, w)
ui.rect(p, x, y, w, 20, lit, 0.08)
@@ -346,7 +374,7 @@ function M.aside(self, root, w, h, H)
if not t then
return false
end
local open = st.unlocked.store -- upgrade, attachments, selling (onboarding.go)
local open = st.unlocked.store -- upgrades, selling (onboarding.go)
local pad = 18
local inner = w - pad * 2
local area = scroll.area(self, root, 0, 2, w, h - 4, "drill")
@@ -356,7 +384,7 @@ function M.aside(self, root, w, h, H)
kids[#kids + 1] = b
end
-- Head: image, name, tier (and "Mining now"), close.
-- Head: image, name, track levels (and "Mining now"), close.
local img = 84
local tw = inner - img - 14
add(row({ height = img }, {
@@ -368,10 +396,12 @@ function M.aside(self, root, w, h, H)
space(14),
col({ grow = 1, shrink = 1 }, {
fg.label(c, t.name, "h2", 28, { fit = true, shrink = 1, max_width = tw - 40 }),
fg.label(c, "T" .. t.tier, { font = "mono", px = 13, color = "muted" }, 20),
-- Its levels (" Y2 C1" without the space), or a line saying it has none (the row keeps its height).
fg.label(c, ui.levels_tag(t) ~= "" and ui.levels_tag(t):sub(2) or "No upgrades yet",
{ font = "mono", px = 13, color = "muted" }, 20),
d.mining and space(0, 4) or nil,
d.mining and fg.measured(c, function(p, x, y, bw)
return cards.para(p, open and "Mining now. Changes apply from its next drill." or "Mining now.", { font = "sans", px = 13, color = "muted" }, x, y, bw)
return cards.para(p, open and "Mining now. Upgrades apply from its next drill." or "Mining now.", { font = "sans", px = 13, color = "muted" }, x, y, bw)
end) or nil,
}),
fg.button(c, "×", { small = true, w = 32 }, function(b)
@@ -380,30 +410,36 @@ function M.aside(self, root, w, h, H)
}))
add(space(0, 14))
-- Stats and the upgrade.
-- Stats and the two tracks.
add(col({ padding = { 4, 0, 6, 0 } }, { rule(), stats_box(st, c, t) }))
local u = open and t.preview
if u and u.rateTo > u.rateFrom + 0.5 then
-- "Income ₥1k/h ▸ ₥2k/h · pays back in 5m": the fonts have no arrow, cards.arrow draws it.
local m12 = { font = "mono", px = 12, color = "muted" }
add(row({ height = 16, align = "center" }, {
fg.label(c, "Income " .. theme.currency .. cards.compact(u.rateFrom) .. "/h", m12, 16), space(5),
made({ width = 13, height = 16, shrink = 0 }, function(p, x, y) cards.arrow(p, x, y + 8) end),
fg.label(c, theme.currency .. cards.compact(u.rateTo) .. "/h"
.. (u.paybackSecs and (" · pays back in " .. cards.span(u.paybackSecs)) or ""), m12, 16),
}))
add(space(0, 6))
end
if open then
add(space(0, 4))
add(upgrade_box(self, H, st, c, t, inner, false))
add(tracks_box(self, H, st, c, t, inner))
end
add(space(0, 8))
-- Tier ladder.
if #(d.ladder or {}) > 0 then
add(h3(c, "Tiers"))
add(ladder(st, c, d, inner))
-- Each track's ladder, with what its next level adds per hour.
local m12 = { font = "mono", px = 12, color = "muted" }
for _, tr in ipairs(t.tracks or {}) do
add(row({ height = 20, align = "center" }, {
fg.label(c, tr.name, "h3", 12), space(8),
fg.label(c, tr.kind == "cycle" and "cycle" or "yield per cycle", { font = "sans", px = 12, color = "muted" }, 12),
}))
add(space(0, 6))
local n = open and tr.next
if n and n.rateTo > n.rateFrom + 0.5 then
-- "Next level: ₥1k/h ▸ ₥2k/h · pays back in 5m": the fonts have no arrow, cards.arrow draws it.
add(row({ height = 16, align = "center" }, {
fg.label(c, "Next level: " .. theme.currency .. cards.compact(n.rateFrom) .. "/h", m12, 16), space(5),
made({ width = 13, height = 16, shrink = 0 }, function(p, x, y) cards.arrow(p, x, y + 8) end),
fg.label(c, theme.currency .. cards.compact(n.rateTo) .. "/h"
.. (n.paybackSecs and (" · pays back in " .. cards.span(n.paybackSecs)) or ""), m12, 16, { fit = true, shrink = 1 }),
}))
add(space(0, 6))
elseif open then
add(space(0, 22)) -- (the line's room, kept at the top level: Sell drill stays put)
end
add(ladder(st, c, tr, inner))
end
-- Idle copies while this one mines: deploy one on its best well.
@@ -417,12 +453,12 @@ function M.aside(self, root, w, h, H)
gui.delete_node(probe.node)
add(col({ height = 60 }, {
row({ height = 20, align = "center" }, {
-- "N idle <tool>" opens that copy's panel (the web's IdleURL), then its tier.
-- "N idle <tool>" opens that copy's panel (the web's IdleURL), then its levels.
made({ width = lw, height = 20, shrink = 0 }, function(p, x, y)
H.text_link(self, p, link, lstyle, x, y + 10, drill_view(self, d.idleBest), "idle-copy")
end),
space(6),
fg.label(c, "T" .. (copy and copy.tier or t.tier), { font = "mono", px = 12, color = "muted" }, 20),
fg.label(c, ui.levels_tag(copy or t):sub(2), { font = "mono", px = 12, color = "muted" }, 20),
}),
fg.label(c, to and ("Best well: " .. to.resource .. " on " .. to.moonName) or "No well to deploy on", "small", 16),
to and fg.button(c, "Deploy one", { small = true, primary = true }, function(b, nodes)
@@ -431,118 +467,12 @@ function M.aside(self, root, w, h, H)
}))
end
-- The Store's parts: slots, spare attachments, selling the drill.
-- The Store's part: selling the drill.
if open then
-- Slots.
add(h3(c, "Slots"))
local slots = {}
for _, sl in ipairs(d.slots) do
local rest
if sl.attachment ~= "" then
local a = catalog_attachment(st, sl.attachment)
rest = {
col({ padding_top = 5, shrink = 0 }, {
made({ width = 32, height = 32 }, function(p, x, y)
cards.image(ui.rect(p, x, y, 32, 32, "bg"), a.image, 0, 0, 32)
end),
}),
space(8),
col({ grow = 1, shrink = 1, padding_top = 2 }, {
fg.label(c, sl.attachment, { font = "sans", px = 15, color = "text" }, 20),
fg.label(c, a.effectText or cards.effect(a.effect, "attachment"), { font = "mono", px = 12, color = "violet_hi" }, 16),
}),
col({ padding_top = 5 }, {
fg.button(c, "Unequip", { small = true }, function(b, nodes)
H.action(self, b, nodes, "unequip", { item = sl.attachment, to = d.toolId })
end),
}),
}
else
rest = { space(4), row({ align = "center" }, { fg.label(c, "Empty", { font = "sans", px = 15, color = "muted" }, 16) }) }
end
local r = row({ height = 42 }, {
rule(),
row({ width = 18, padding_left = 2, align = "center", shrink = 0 }, { fg.label(c, sl.n, { font = "mono", px = 12, color = "muted" }, 16) }),
})
for _, k in ipairs(rest) do
r.children[#r.children + 1] = k
end
slots[#slots + 1] = r
end
add(col({}, slots))
add(space(0, 8))
-- Spare attachments: equip (with the stats it'd give), sell one.
add(h3(c, "Spare attachments"))
if #d.spares == 0 then
add(row({ height = 40, align = "start" }, {
row({ grow = 1, height = S.button_sm_h, align = "center" }, { fg.label(c, "No spare attachments.", "small", 16) }),
fg.button(c, "Buy some in the store", { small = true }, function(b)
H.link(self, b, { section = "store", moon = self.view.moon })
end),
}))
end
local m12 = function(color) return { font = "mono", px = 12, color = color } end
for _, sp in ipairs(d.spares) do
local a = catalog_attachment(st, sp.name)
local sw = fg.button_w(c, "Sell", { small = true, variant = "danger" })
-- Sell asks in place (cards.ask_button): the question where the text was, Confirm
-- where Equip was, Cancel on Sell's spot.
local sell_id = "confirm:sell-attachment" .. (#d.spares > 1 and (":" .. sp.name) or "")
local asking = cards.asking(self, sell_id, sp.confirm, { item = sp.name })
local ask_ok, ask_cancel
if asking then
ask_ok, ask_cancel = cards.ask_buttons(self, c, sp.confirm, sw)
end
add(row({ height = sp.canEquip and 50 or 58 }, {
rule(),
col({ padding_top = 6, shrink = 0 }, {
made({ width = 36, height = 36 }, function(p, x, y)
cards.image(ui.rect(p, x, y, 36, 36, "bg"), a.image, 0, 0, 36)
end),
}),
space(10),
asking and col({ grow = 1, shrink = 1, basis = 0, padding_top = 6, padding_right = 8 }, { cards.ask_text(sp.confirm, c) }) or nil,
asking and row({ gap = 6, padding_top = 8, align = "start", shrink = 0 }, { ask_ok, ask_cancel }) or nil,
(not asking) and col({ grow = 1, shrink = 1, padding_top = 6 }, {
row({ height = 16, align = "center" }, {
fg.label(c, sp.name, { font = "sans", px = 15, color = "text" }, 16),
sp.count > 1 and space(6) or nil,
sp.count > 1 and fg.label(c, "×" .. sp.count, m12("muted"), 16) or nil,
}),
space(0, 2),
-- What the drill would do with it: effect, then cycle and yield (stat_up where better).
row({ height = 16, align = "center" }, {
fg.label(c, a.effectText or cards.effect(a.effect, "attachment"), m12("violet_hi"), 16), space(6),
fg.label(c, "· then", m12("muted"), 16), space(5),
fg.label(c, ui.cycle(sp.cycleSecs), m12(sp.cycleSecs < t.cycleSecs and "stat_up" or "text_2"), 16),
fg.label(c, ", +" .. ui.money(sp.yield), m12(sp.yield > t.yield + 1e-9 and "stat_up" or "text_2"), 16),
}),
}) or nil,
(not asking) and row({ gap = 6, padding_top = 8, align = "start" }, {
fg.button(c, "Equip", { small = true, primary = true, disabled = not sp.canEquip }, function(b, nodes)
if sp.canEquip then
H.action(self, b, nodes, "equip", { item = sp.name, to = d.toolId })
else
tooltip.add(b, sp.whyText)
end
end),
fg.button(c, "Sell", { small = true, variant = "danger" }, function(b)
tooltip.add(b, "Sell one for " .. theme.currency .. ui.money(sp.resale))
cards.ask_button(self, b, sp.confirm, "sell-attachment", { item = sp.name }, sell_id)
end),
}) or nil,
not asking and not sp.canEquip and fg.label(c, sp.whyText, { font = "sans", px = 11, color = "muted" }, 16,
{ position = "absolute", top = 38, right = sw + 6 }) or nil,
}))
end
add(space(0, 8))
-- Sell the drill.
add(h3(c, "Sell drill"))
local why = d.noSale ~= "" and d.noSaleText or nil
local note = "Part of what it and its upgrades cost. Refined parts aren't refunded"
.. (#(d.gear or {}) > 0 and "; its attachments go back to your inventory." or ".")
local note = "Part of what it and its upgrades cost. Refined parts aren't refunded."
-- Sell asks in place (cards.ask_button): the question under the price instead of the
-- note, Confirm left of the Sell button's spot, Cancel on it.
local asking = not why and cards.asking(self, "confirm:sell-tool", d.confirm, { tool = d.toolId })
+2 -2
View File
@@ -1,5 +1,5 @@
-- Selling a moon (state.moon.sale, the web's "moon-sale" panel; here at the foot of the
-- aside's well panel, aside.lua):
-- Moons section's main panel, moons.lua):
-- the offer and how it compares with a new moon, the Sell moon button (asks first, in
-- place with the server's question: cards.ask_button; why it can't be sold yet is its tooltip), and on demand the
-- note (with the hold countdown, "Sellable in") and the lines: what's left in each well at
@@ -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 (the aside puts it under the selected well).
-- The panel, w wide: a tree piece (moons.lua puts it under the idle drills).
function M.box(self, H, c, moon, w)
local sale = moon.sale
self.sale_open = self.sale_open or {}
+479 -222
View File
@@ -1,8 +1,9 @@
-- The Moons section (main panel): the selected moon (id, name, class and rating stars,
-- description, Claim), its wells table (supply bars, drill timer bars, claim / countdown /
-- redrill per well, the multi-well row); a mined-out
-- moon's summary; the first-moon pitch when there is none. Also the well helpers the
-- aside's well panel shares (supply_bar, supply_max, tool_name).
-- description, Claim N drills), its wells as cards with every action on a well (deploy,
-- claim, claim and redeploy, redrill: "well cards" below), the multi-well card, the tray
-- of idle drills and selling the moon; a mined-out moon's summary; the first-moon pitch
-- when there is none. The aside only shows facts (aside.lua). Also the well helpers the
-- aside shares (supply_bar, supply_max, tool_name, short_name).
local theme = require("modules.theme")
local ui = require("modules.ui")
@@ -15,6 +16,10 @@ local scroll = require("components.scroll")
local ready = require("screens.game.core.ready")
local flex = require("modules.flex")
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
@@ -100,10 +105,10 @@ function M.track_drills(g)
end
end
-- A tool's name and tier by id.
-- A tool's name and its levels tag (ui.levels_tag: " Y2 C1", "" for a base tool) by id.
function M.tool_name(st, id)
local t = find(st.tools, id)
return t and t.name or "Tool", t and t.tier or 0
return t and t.name or "Tool", ui.levels_tag(t)
end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
@@ -169,220 +174,460 @@ local function past_moon(g, root, px, py, inner, moon, H)
})
fg.build(flex.solve(tree, px, py), root)
end
-- Timer bars for a drill queue, oldest first (the web's drill-stack): thin bars 40 wide
-- stacked, filling while it runs, full when ready, tinted by what it gets (ok, short,
-- none); a multi-well drill has a diamond cap. At most 4 bars, then "+N" beside them.
-- row: the row's key (a multi-well drill has a bar on every row it reaches). y is the
-- stack's middle.
local MAX_BARS, BAR_W, BAR_H, BAR_GAP = 4, 40, 3, 2
local MORE = { font = "mono", px = 11, color = "muted" }
local function drill_bars(g, parent, x, y, drills, row_key)
local n = math.min(#drills, MAX_BARS)
local top = y - math.floor((n * (BAR_H + BAR_GAP) - BAR_GAP) / 2)
for i = 1, n do
local d = drills[i]
local color = d.state == "short" and "ready" or "cyan"
local by = top + (i - 1) * (BAR_H + BAR_GAP)
-- Keyed by row and tool (one drill each), so a restarted or corrected drill eases.
bar.timer(g, parent, x, by, BAR_W, BAR_H, { start = d.start, ends = d["end"], ready = d.ready, color = color,
none = d.state == "none", key = "bar:" .. row_key .. ":" .. d.toolId })
if (d.reach or 1) > 1 then
-- The diamond cap (app.css .drill-bar.is-wide::after).
local cap = ui.rect(parent, 0, 0, 5, 5, d.state == "none" and "muted" or color)
gui.set_pivot(cap, gui.PIVOT_CENTER)
gui.set_position(cap, vec(x + BAR_W + 1, -(by + 1)))
gui.set_rotation(cap, vmath.quat_rotation_z(math.rad(45)))
---------------------------------------------------------------- well cards
-- The wells as cards (Drilling B · Well cards): every action on a well is on its card,
-- the aside only shows. A card: the resource and its supply, the supply bar and a line
-- of what the drills leave, then a stack of slots, all SLOT_H tall: the deploy slot
-- first (it keeps its spot while you deploy again and again: new drills go under it),
-- then a slot per drill, oldest first (time left and its bar; when it's done, gold with
-- Claim and Redeploy on it), then one slot for the idle drills that last mined the well
-- (Redrill). Claiming one slot claims every ready drill of the well (claim-well); the
-- next slot slides into its place. Clicking the card (not a button) selects the well:
-- 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.
local SLOT_H, SLOT_GAP = 60, 6
local CARD_MIN, CARD_GAP, CARD_PAD = 210, 12, 12
local TILE_MIN, TILE_H = 150, 52
local SLOT_BTN_H = 26
local EPS = 1e-9
local NAME13 = { font = "sans_semi", px = 13, color = "text" }
local M12 = { font = "mono", px = 12, color = "muted" }
local S12 = { font = "sans", px = 12, color = "muted" }
local S11 = { font = "sans", px = 11, color = "muted" }
-- A tool's short name ("PD-1 T0"): its code (else the last word of its name), and its
-- track levels when it has any ("PD-1 Y2 C1", ui.levels_tag). t: a state.tools entry
-- (nil: "Drill").
function M.short_name(t)
if not t then
return "Drill"
end
local s = (t.code and t.code ~= "") and t.code or ((t.name or "Drill"):match("(%S+)$") or "Drill")
s = s .. ui.levels_tag(t)
return s
end
-- The web's short wording of a reach ("3 wells", "whole moon"; nil for one).
local function reach_short(st, n)
if not n or n <= 1 then
return nil
end
local list = st.catalog and st.catalog.reach or {}
local pick = list[#list]
for _, r in ipairs(list) do
if r.reach == n then
pick = r
end
end
return pick and pick.short ~= "" and pick.short or (n .. " wells")
end
-- Idle tools grouped like copies (name, levels, yield, cycle, reach; read generically), in
-- order of yield, then key: { key, tool (one idle copy), count }.
function M.idle_groups(st)
local out, by = {}, {}
for _, t in ipairs(st.tools or {}) do
if not t.stakeId then
local k = table.concat({ t.code or t.name or "", ui.levels_tag(t), tostring(t.yield),
tostring(t.cycleSecs), tostring(t.reach or 1) }, ":")
local grp = by[k]
if not grp then
grp = { key = k, tool = t, count = 0 }
by[k], out[#out + 1] = grp, grp
end
grp.count = grp.count + 1
end
end
-- (in a fixed order, not the server's list's: a claimed drill coming back idle mustn't
-- shuffle the tiles; smallest yield first, so an unpicked deploy slot sends the
-- smallest drill with room)
table.sort(out, function(a, b)
local ya, yb = a.tool.yield or 0, b.tool.yield or 0
if ya ~= yb then
return ya < yb
end
return a.key < b.key
end)
return out
end
-- What a cycle of tool t would get on the well after the moon's drills (predict.room), and
-- its full amount there (the yield with the resource's live boost).
local function gets_here(st, well, t)
local boosts = st.rules and st.rules.boosts and st.rules.boosts.yield or {}
local amount = (t.yield or 0) * (boosts[well.resource] or 1)
return predict.room(st, well.id, amount, t.reach or 1) or amount, amount
end
-- The group a well's deploy slot sends: the tray's pick (explicit = true, even when it has
-- no room there), else the first one-well group with room. Returns group, here, amount,
-- explicit.
local function deploy_choice(g, st, well, groups)
for _, grp in ipairs(groups) do
if grp.key == g.deploy_pick then
local here, amount = gets_here(st, well, grp.tool)
return grp, here, amount, true
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
if here > EPS then
return grp, here, amount, false
end
end
end
local function drill_label(st, drills)
if #drills == 0 then
return ""
elseif #drills == 1 then
local name, tier = M.tool_name(st, drills[1].toolId)
return name:match("(%S+)$") .. " T" .. tier
-- An action button's test id (core/hooks: "act:<name>?<sorted params>").
local function act_id(name, params)
local keys = {}
for k, v in pairs(params) do
keys[#keys + 1] = k .. "=" .. tostring(v)
end
return ui.plural(#drills, "drill")
table.sort(keys)
return "act:" .. name .. "?" .. table.concat(keys, "&")
end
-- The drill column of a row, built at its width (52 tall): the stacked timer bars for
-- every drill on it, multi-well ones included (the web's Queue), and the label of its
-- own drills (DrillLabel) beside them, or under them (after "+N") when there's no room
-- (decided at its width). Its tooltip (DrillsTitle): each drill, oldest first, and what
-- it gets. pad: room kept before the status buttons.
local function drill_cell(g, queue, fates, label, row_key, pad)
return made({ grow = 1, shrink = 1, padding_right = pad }, function(p, x, y, w, h)
local dw = w - pad
if #fates > 0 then
tooltip.add(ui.rect(p, x, y + 6, math.max(40, dw), h - 12, "none"), table.concat(fates, "\n"))
end
local n = math.min(#queue, MAX_BARS)
local stack_h = n * (BAR_H + BAR_GAP)
local more = #queue - n
local function bars(beside)
local more_l = beside and more > 0 and fg.label(p, "+" .. more, MORE, 16) or nil
return row({ shrink = 0, align = "center" }, {
made({ width = BAR_W + 4, height = stack_h, shrink = 0 }, function(q, bx, by)
drill_bars(g, q, bx, by + math.floor(stack_h / 2), queue, row_key)
end),
more_l and space(2) or nil, more_l,
})
end
local tree
if #queue == 0 then
tree = row({ height = h, align = "center" }, {
fg.label(p, "Empty", { font = "sans", px = 14, color = "muted" }, 16, { fit = true, shrink = 1 }),
})
-- A one-line label box in a slot or tile (cut with … when it doesn't fit).
local function fit(p, str, style, opts)
opts = opts or {}
opts.fit, opts.shrink = true, opts.shrink or 1
return fg.label(p, str, style, 16, opts)
end
-- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a
-- cycle gets here, or why it can't (no idle drills, no room).
local function deploy_slot(g, st, well, groups, H)
local grp, here, amount, explicit = deploy_choice(g, st, well, groups)
local ok = grp ~= nil and here > EPS
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not ok })
local title, sub, sub_color
if #groups == 0 then
title, sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
elseif not grp then
title, sub = "No room here", "The drills on it take all it has"
elseif not ok then
title, sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
else
local style = { font = "sans", px = 12, color = "text_2" }
local probe = fg.label(p, label, style, 16)
local lw = probe.measure()
gui.delete_node(probe.node)
if BAR_W + 4 + (more > 0 and 18 or 0) + 6 + lw <= dw then
tree = row({ height = h, align = "center" }, {
bars(true), space(6), fg.label(p, label, style, 16, { fit = true, shrink = 1 }),
})
else
-- (narrow: "+N" leads the label under the bars)
tree = col({ height = h, padding_top = 13, align = "start" }, {
made({ width = BAR_W + 4, height = stack_h, shrink = 0 }, function(q, bx, by)
drill_bars(g, q, bx, by + math.floor(stack_h / 2), queue, row_key)
end),
space(0, 1),
fg.label(p, (more > 0 and ("+" .. more .. " ") or "") .. label, style, 16, { fit = true, shrink = 1 }),
})
local t = grp.tool
local reach = reach_short(st, t.reach)
title = "+ Deploy " .. M.short_name(t)
sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
if here < amount - EPS then
sub, sub_color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
end
end
fg.build(flex.solve(tree, x, y, dw), p)
local tree = col({ width = w, height = SLOT_H, padding = { 0, 10 }, justify = "center", align = "center", gap = 2 }, {
fit(b, title, { font = "sans_semi", px = 13, color = ok and "cyan" or "muted", tracking = 0.06, upper = true }),
fit(b, sub, { font = "sans", px = 12, color = sub_color or "muted" }),
})
fg.build(flex.solve(tree, 0, 0), b)
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)"))
end
end)
end
-- Table columns (468 wide at 1280x800; wider tables spread the columns): the supply
-- column up to the web's 200 px, the drill column takes what the row's buttons leave.
local function table_cols(inner)
local c2 = math.max(118, math.floor(inner * 0.25))
return { c2, math.max(92, math.min(212, math.floor(inner * 0.2))), "1fr", "auto" }
end
-- One row of the wells table (a well, or the multi-well group): a grid row 52 tall, its
-- row node (hover, selection, entrance) in a slot. enters: row entrances to run once the
-- page is built (they veil the row's content).
local function well_row(g, c, cols, max, moon, well, H, enters)
local st, v = g.state, g.view
local group = well.wide_group or well
local key = well.id or "wide"
local function made_row(parent, x, y, w, h)
local slot = ui.rect(parent, x, y, w, h, "none")
local r = ui.rect(slot, 0, 0, w, h, "cyan", 0)
ui.rect(r, 0, h - 1, w, 1, "line")
if not well.wide_group then
-- Row click selects the well (bound first: the buttons on it take precedence).
row_c.bind(g, r, w, h, { selected = well.id == v.well, id = "well:" .. well.id, label = well.resource,
on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
g.well_nodes[well.id] = r
end
-- A drill was just deployed here: the row slides in (fx-new).
enters[#enters + 1] = function()
row_c.enter(g, "well:" .. key, r, w, h, { base = gui.get_color(r) })
end
return r
end
-- Drill queue: its own drills, and multi-well ones that reach it, oldest first.
local drills = group.drills or {}
local queue = {}
for _, d in ipairs(drills) do
queue[#queue + 1] = d
end
if not well.wide_group and moon.wide then
local reach = {}
for _, id in ipairs(well.wide or {}) do
reach[id] = true
end
for _, d in ipairs(moon.wide.drills or {}) do
if reach[d.id] then
queue[#queue + 1] = d
-- A drill's slot (SLOT_H tall; slides in when it was just deployed): its short name and
-- time left over its timer bar and what it gets; done (ready, or its end passed on our
-- clock): gold, "+X ready", Claim (scope: claim-well or claim-wide) and, once unlocked,
-- Redeploy. n: its place among the group's ready slots (the first one's buttons have
-- the plain action ids). wait: the group's next drill to finish; where its Claim and
-- Redeploy will show is registered as "wait:claim?..." (like H.claim_wait's waiting
-- button; not focusable), for the test driver.
local function drill_slot(g, st, d, scope, can_redeploy, n, wait, H, enters)
local tool = find(st.tools, d.toolId)
local due = d.ready or (d["end"] or math.huge) <= H.now(g)
local reach = reach_short(st, d.reach)
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
local slot = ui.rect(p, x, y, w, SLOT_H, "none")
local body = ui.rect(slot, 0, 0, w, SLOT_H, due and "ready" or "cyan", due and 0.08 or 0.04)
ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil)
local iw = w - 18
local right
local gets = d.here or d.take or d.amount or 0
if due then
-- (one that gets nothing is claimed all the same: it frees the drill)
right = fg.label(body, gets > 0 and ("+" .. ui.money(gets) .. " ready") or "Nothing left",
{ font = "sans_semi", px = 12, color = gets > 0 and "ready" or "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 })
else
right = fg.label(body, ui.dur(d["end"] - H.now(g)), M12, 16, { shrink = 0 })
right.done = function(t)
H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
end
end
table.sort(queue, function(a, b) return (a.start or 0) < (b.start or 0) end)
end
local fates = {}
for _, d in ipairs(queue) do
fates[#fates + 1] = d.fate
end
local label = (group.label or "") ~= "" and group.label or drill_label(st, drills) -- (the server's DrillLabel)
-- Name and supply cells.
local ML = { font = "mono", px = 12, color = "muted" }
local name_cell, supply_cell
if well.wide_group then
name_cell = row({ padding_left = 26, align = "center" }, { fg.label(c, "Multi-well", "cap", 16) })
-- "X left on the moon", wrapping onto a second line when the column is narrow.
supply_cell = col({ padding_right = 8, justify = "center" }, {
row({ wrap = true, column_gap = 5, row_gap = 1 }, {
fg.label(c, ui.money(group.cap), ML, 16),
fg.label(c, "left on the moon", { font = "sans", px = 12, color = "muted" }, 16, { fit = true, shrink = 1 }),
}),
})
else
local dry = well.supply <= 0
name_cell = row({ padding_left = 26, align = "center" }, {
made({ position = "absolute", left = 10, top = 22, width = 8, height = 8 }, function(q, x, y)
ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1)
end),
fg.label(c, well.resource, { font = "sans", px = 15, color = dry and "muted" or "text" }, 16),
})
if dry then
supply_cell = row({ align = "center" }, {
fg.label(c, "Tapped out", { font = "sans_semi", px = 12, color = "muted", tracking = 0.1, upper = true }, 16),
})
local second
if due then
local suffix = n > 1 and ("#" .. n) or ""
local kids = {
fg.button(body, "Claim", { primary = true, small = true, h = SLOT_BTN_H }, function(b, bn)
H.action(g, b, bn, "claim", scope, nil, nil, n > 1 and (act_id("claim", scope) .. suffix) or nil)
tooltip.add(b, "Claim every ready drill here")
end, { grow = 1, height = SLOT_BTN_H }),
}
if can_redeploy then
local rp = { redeploy = "1" }
for k, v in pairs(scope) do
rp[k] = v
end
kids[2] = space(6)
kids[3] = fg.button(body, "Redeploy", { small = true, h = SLOT_BTN_H }, function(b, bn)
H.action(g, b, bn, "claim", rp, nil, nil, n > 1 and (act_id("claim", rp) .. suffix) or nil)
tooltip.add(b, "Claim, then send the drills out again")
end, { height = SLOT_BTN_H })
end
second = row({ height = SLOT_BTN_H, align = "center" }, kids)
else
local sup = fg.label(c, ui.money(well.supply), ML, 16)
sup.done = function(n) H.tick_num(g, n, "supply:" .. well.id, well.supply) end
supply_cell = col({ padding = { 20, 14, 0, 0 } }, {
made({ height = 6 }, function(q, x, y, w) M.supply_bar(q, x, y, w, well, max) end),
space(0, 2), sup,
local color = d.state == "short" and "ready" or "cyan"
local gets
if d.state == "none" then
gets = fit(body, "Nothing left by its turn", { font = "sans", px = 11, color = "ready" })
else
local here = ui.money(d.here or d.take or 0)
gets = fit(body, "Gets " .. here .. (d.state == "short" and (" of " .. ui.money(d.amount)) or "")
.. ((d.reach or 1) > 1 and (d.gets and d.gets ~= d.here) and (" · " .. ui.money(d.gets) .. " in all") or ""),
{ font = "sans", px = 11, color = d.state == "short" and "ready" or "muted" })
end
second = col({ height = SLOT_BTN_H, gap = 4, padding_top = 3 }, {
made({ height = 5, shrink = 0 }, function(q, bx, by, bw)
bar.timer(g, q, bx, by, bw, 5, { start = d.start, ends = d["end"], ready = false, color = color,
none = d.state == "none", key = "slot:" .. d.toolId })
end),
gets,
})
end
end
-- Status (right): Claim (ready), Claim countdown (mining), Redrill (idle tools that
-- mined it), then Manage (View when tapped out), like the web's row actions.
-- (the amount moves on as more of its drills finish on our clock: core/ready)
local scope = well.wide_group and { ["claim-wide"] = moon.id } or { ["claim-well"] = well.id }
local amount = ready.amount(g, group, well.wide_group and group.cap or well.supply)
local status = {}
if group.ready > 0 then
status[#status + 1] = fg.button(c, "Claim " .. ui.money(amount), { primary = true, small = true }, function(b, bn)
H.action(g, b, bn, "claim", scope)
end)
elseif group.nextEnd then
local wopts = { small = true, right = true, key = "wait:" .. key, params = scope, moon = moon.id,
done = amount > 0 and ("Claim " .. ui.money(amount)) or nil }
local bw = H.claim_wait_w(g, c, group.nextEnd, wopts)
status[#status + 1] = made({ width = bw, height = S.button_sm_h, shrink = 0 }, function(q, x, y, w)
H.claim_wait(g, q, x + w, y, group.nextEnd, wopts)
end)
elseif not well.wide_group and (well.redrill or 0) > 0 and well.supply > 0 and st.unlocked.redrill then
status[#status + 1] = fg.button(c, "Redrill", { small = true, variant = "redrill" }, function(b, bn)
H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
end)
end
if not well.wide_group then
local to = { section = "moons", moon = moon.id, well = well.id }
status[#status + 1] = fg.button(c, well.supply <= 0 and "View" or "Manage", { small = true }, function(b, bn)
button.bind(g, b, bn, { id = "manage:" .. well.id, on_click = function() H.navigate(g, to) end })
end)
end
return box({ columns = cols, rows = { S.well_row_h }, padding_right = 8 }, {
name_cell, supply_cell,
drill_cell(g, queue, fates, label, key, #status > 0 and 14 or 8),
row({ gap = 6, align = "center" }, status),
}, { make = made_row })
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
row({ height = 16, align = "center" }, {
fit(body, M.short_name(tool), NAME13),
reach and space(5) or nil, reach and fit(body, "· " .. reach, S12) or nil,
fg.fill(), space(6), right,
}),
second,
})
fg.build(flex.solve(tree, 0, 0), body)
if wait and not due then
local function spot(params, style)
return made(style, function(q, bx, by, bw, bh)
test.register("wait:" .. act_id("claim", params):sub(5), ui.rect(q, bx, by, bw, bh, "none"), "wait", nil, nil, true)
end)
end
local kids = { spot(scope, { grow = 1, height = SLOT_BTN_H }) }
if can_redeploy then
local rp = { redeploy = "1" }
for k, v in pairs(scope) do
rp[k] = v
end
kids[2] = space(6)
kids[3] = spot(rp, { width = fg.button_w(body, "Redeploy", { small = true, h = SLOT_BTN_H }), height = SLOT_BTN_H, shrink = 0 })
end
fg.build(flex.solve(row({ width = w - 18, height = SLOT_BTN_H }, kids), 9, 27), body)
end
if d.fate and d.fate ~= "" then
tooltip.add(ui.rect(body, 0, 0, iw, 23, "none"), d.fate)
end
enters[#enters + 1] = function()
row_c.enter(g, "drill:" .. d.toolId, body, w, SLOT_H, { base = gui.get_color(body) })
end
end)
end
-- The slot of the idle drills that last mined the well (well.redrill): how many, and
-- Redrill (sends them out again) once unlocked and the well has supply.
local function idle_slot(g, st, well, H)
local n = well.redrill or 0
local names = {}
for _, t in ipairs(st.tools or {}) do
if #names < 2 and not t.stakeId and predict.memo.last_well[t.id] == well.id then
names[#names + 1] = M.short_name(t)
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
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")
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
row({ height = 16, align = "center" }, {
fit(body, who, { font = "sans_semi", px = 13, color = "text_2" }), fg.fill(), space(6),
fg.label(body, "Idle", { font = "sans_semi", px = 12, color = "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 }),
}),
can and fg.button(body, "Redrill" .. (n > 1 and (" " .. n) or ""), { small = true, variant = "redrill", h = SLOT_BTN_H }, function(b, bn)
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),
}),
})
fg.build(flex.solve(tree, 0, 0), body)
end)
end
-- A well's line under its supply bar: the over-drilled lead in amber, else what the
-- drills leave ("45 free after drills"), else nothing (the line is kept).
local function well_note(c, well)
if well.supply <= 0 then
return fit(c, "Tapped out", { font = "sans_semi", px = 12, color = "muted", tracking = 0.1, upper = true })
elseif (well.statusText or "") ~= "" then
-- (its lead: "Over-drilled", "Fully committed"; the aside has the rest)
return fit(c, well.statusText:match("^(.-)%s+—") or well.statusText, { font = "sans", px = 12, color = "ready" })
elseif (well.demand or 0) > 0 then
return fit(c, ui.money(well.remain or 0) .. " free after drills", { font = "sans", px = 12, color = well.full and "ready" or "muted" })
end
end
-- The drill of a list that finishes next (not done yet), or nil.
local function next_drill(g, drills)
local now, pick = g.h.now(g), nil
for _, d in ipairs(drills or {}) do
if not d.ready and d["end"] and d["end"] > now and (not pick or d["end"] < pick["end"]) then
pick = d
end
end
return pick
end
-- One well's card (a grid cell; its node selects the well when clicked).
local function well_card(g, c, st, moon, well, max, groups, H, enters)
local dry = well.supply <= 0
local selected = well.id == g.view.well
local scope = { ["claim-well"] = well.id }
local slots = {}
if not dry then
slots[#slots + 1] = deploy_slot(g, st, well, groups, H)
end
local n, next_d = 0, next_drill(g, well.drills)
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)
end
if (well.redrill or 0) > 0 then
slots[#slots + 1] = idle_slot(g, st, well, H)
end
local sup = fit(c, ui.money(well.supply), { font = "mono", px = 13, color = dry and "muted" or "text" }, { shrink = 0 })
sup.done = function(node) H.tick_num(g, node, "supply:" .. well.id, well.supply) end
local kids = {
row({ height = 18, align = "center" }, {
made({ width = 8, height = 8, shrink = 0 }, function(q, x, y) ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1) end),
space(8), fit(c, well.resource, { font = "sans_semi", px = 15, color = dry and "muted" or "text" }),
fg.fill(), space(6), sup,
}),
space(0, 7),
made({ height = 6, shrink = 0 }, function(q, x, y, w)
if dry then
ui.rect(q, x, y, w, 6, "line")
else
M.supply_bar(q, x, y, w, well, max)
end
end),
space(0, 4),
fg.reserve({ height = 16 }, well_note(c, well)),
space(0, 8),
col({ gap = SLOT_GAP }, slots),
}
return col({ padding = CARD_PAD }, kids, { make = function(parent, x, y, w, h)
local slot = ui.rect(parent, x, y, w, h, "none")
local card = ui.rect(slot, 0, 0, w, h, "cyan", 0)
row_c.bind(g, card, w, h, { selected = selected, id = "well:" .. well.id, label = well.resource,
on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
ui.outline(card, 0, 0, w, h, selected and "cyan" or "line_hi", selected and 0.9 or nil)
g.well_nodes[well.id] = card
return card
end })
end
-- The multi-well drills (moon.wide: reach > 1, claimed together with claim-wide): a card
-- across the grid, its slots in the cards' columns.
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)
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)
end
local sw = inner - CARD_PAD * 2
return col({ width = inner, padding = CARD_PAD }, {
row({ height = 18, align = "center" }, {
fg.label(c, "Multi-well", { font = "sans_semi", px = 15, color = "text" }, 18), space(10),
fit(c, ui.money(wide.cap or 0) .. " left on the moon", S12), fg.fill(),
}),
space(0, 10),
flex.grid(sw, CARD_MIN - CARD_PAD * 2, SLOT_GAP, slots),
}, { make = function(parent, x, y, w, h)
local card = ui.rect(parent, x, y, w, h, "cyan", 0)
ui.outline(card, 0, 0, w, h, "line_hi")
return card
end })
end
-- The tray of idle drills: a tile per group (picture, short name, ×count, what a cycle
-- gets); a tile toggles the pick the deploy slots send.
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,
})
if #groups == 0 then
return col({ width = inner }, {
head, space(0, 8),
row({ height = 16, align = "center" }, {
fg.label(c, "No idle drills.", "small", 16),
st.unlocked.store and space(5) or nil,
st.unlocked.store and made({ width = 160, height = 16, shrink = 0 }, function(p, x, y)
H.text_link(g, p, "Buy one in the store", { font = "sans", px = 13 }, x, y + 8, { section = "store", moon = g.view.moon })
end) or nil,
}),
})
end
local tiles = {}
for _, grp in ipairs(groups) do
local t = grp.tool
local picked = grp.key == g.deploy_pick
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 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)
end
local reach = reach_short(st, t.reach)
local tree = col({ width = w, height = TILE_H, padding = { 8, 8, 8, TILE_H }, gap = 4 }, {
row({ height = 16, align = "center" }, {
fit(b, M.short_name(t), NAME13), fg.fill(), space(4),
fg.label(b, "×" .. grp.count, { font = "mono", px = 12, color = picked and "cyan" or "text_2" }, 16, { shrink = 0 }),
}),
fit(b, "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or ""), { font = "mono", px = 11, color = "muted" }),
})
fg.build(flex.solve(tree, 0, 0), b)
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 })
end)
end
return col({ width = inner }, { head, space(0, 8), flex.grid(inner, TILE_MIN, 8, tiles) })
end
local function rating_chip(c, r)
-- The web's rating chip ("★3" in the tier's color; its tooltip the label and percentile).
local edge, alpha, fill = "line_hi", 1, nil
@@ -412,8 +657,8 @@ local function rating_chip(c, r)
end
-- The moon: heading (id, name, class and rating, description; Claim at the right), the
-- wells table (min_h tall at least; the moon's sale is in the aside). Returns its
-- bottom.
-- well cards, the multi-well card, the tray of idle drills, and selling the moon (once
-- the store is open). min_h tall at least. Returns its bottom.
local function moon_view(g, c, px, py, inner, min_h, H)
local st = g.state
local moon = st.moon
@@ -456,32 +701,43 @@ local function moon_view(g, c, px, py, inner, min_h, H)
end)
end
-- Wells table: the header, an edge line, a row per well and the multi-well group.
-- The cards: a well each (as many columns as CARD_MIN allows), the multi-well card
-- across them, then the idle drills.
local max = M.supply_max(st, moon)
local cols = table_cols(inner)
local groups = M.idle_groups(st)
local pick_ok = false
for _, grp in ipairs(groups) do
pick_ok = pick_ok or grp.key == g.deploy_pick
end
if not pick_ok then
g.deploy_pick = nil -- (used up)
end
local enters = {}
local tbl = {
box({ columns = cols, height = 16, align = "start" }, {
fg.label(c, "Well", "cap", 12), fg.label(c, "Supply left", "cap", 12), fg.label(c, "Drill", "cap", 12),
fg.label(c, "Status", "cap", 12, { justify_self = "end" }),
}),
made({ position = "absolute", top = 16, left = 0, right = 0, height = 1 }, function(p, x, y, w)
ui.rect(p, x, y, w, 1, "edge")
end),
}
local cards = {}
for _, well in ipairs(moon.wells) do
tbl[#tbl + 1] = well_row(g, c, cols, max, moon, well, H, enters)
cards[#cards + 1] = well_card(g, c, st, moon, well, max, groups, H, enters)
end
if moon.wide then
tbl[#tbl + 1] = well_row(g, c, cols, max, moon, { wide_group = moon.wide }, H, enters)
end
-- (selling the moon is in the aside, under the well: aside.lua)
local page = col({ width = inner, min_height = min_h }, {
local kids = {
col({ min_height = 80, align = "start" }, head),
space(0, 14),
col({}, tbl),
})
flex.grid(inner, CARD_MIN, CARD_GAP, cards),
}
if moon.wide and #(moon.wide.drills or {}) > 0 then
kids[#kids + 1] = space(0, CARD_GAP)
kids[#kids + 1] = wide_card(g, c, st, moon, inner, H, enters)
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
local page = col({ width = inner, min_height = min_h }, kids)
fg.build(flex.solve(page, px, py), c)
for _, f in ipairs(enters) do
f()
@@ -489,6 +745,7 @@ local function moon_view(g, c, px, py, inner, min_h, H)
return page.y + page.h
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)
+2 -29
View File
@@ -1,4 +1,4 @@
-- The Store section (state.store): tool cards and attachment cards with their images,
-- The Store section (state.store): tool cards with their images,
-- prices, what they'd earn and how many you own. Buying a tool opens the buy modal
-- (the response's buyModal: up to 3 wells to deploy it on).
@@ -185,26 +185,6 @@ local function tool_card(self, H, st, c, t)
end)
end
local function gear_card(self, H, st, c, a)
c:image(a.image)
c:title(a.name)
c:text(a.effectText or cards.effect(a.effect, "attachment"), { font = "mono", px = 14, color = "violet_hi" }, 22)
c:para(a.description, { font = "sans", px = 13, color = "text_2" })
c:gap(4)
local short = not a.canAfford
c:footer(30 + S.button_sm_h + 18, function(f, bw) -- (the need line's place is kept: buy_footer)
M.buy_footer(f, bw, { { "Spare", a.spare }, { "Equipped", a.equipped } }, a.price, short,
(a.whyText or "") ~= "" and a.whyText or ("Need " .. theme.currency .. ui.money(a.price - st.credits) .. " more"),
function(b, nodes)
if short then
tooltip.add(b, a.whyText)
else
H.action(self, b, nodes, "buy-attachment", { item = a.name })
end
end)
end)
end
function M.build(self, root, px, py, w, h, H)
local st = self.state
local sto = st.store
@@ -217,11 +197,10 @@ function M.build(self, root, px, py, w, h, H)
local function cap(label, before)
return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) })
end
local attachments = #(sto.attachments or {}) > 0
y = fg.flow(c, x, y, inner, {
fg.col({}, {
fg.label(c, "Store", "h1", 40), fg.space(0, 4),
fg.text_para(c, "Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Equip attachments in Equipment.",
fg.text_para(c, "Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Upgrade them in Equipment.",
{ font = "sans", px = 15, color = "muted" }, { max_width = math.min(inner, S.text_max) }),
fg.space(0, 20),
}),
@@ -231,12 +210,6 @@ function M.build(self, root, px, py, w, h, H)
tool_card(self, H, st, cd, t)
end, { key = function(t) return "store:tool:" .. tostring(t.storeItemId) end, sig = shared })
end,
attachments and cap("Attachments", 8) or nil,
attachments and function(cy)
return card.grid(self, c, x, cy, inner, sto.attachments, function(cd, a)
gear_card(self, H, st, cd, a)
end, { key = function(a) return "store:gear:" .. tostring(a.name) end, sig = shared })
end or nil,
})
area:finish(y + py)
end
+7 -7
View File
@@ -5,7 +5,7 @@ Usage: scripts/fixture.py <api base> [--plain]
Prints one JSON line: {"player", "moon", "well", "wells", "tool", "drill_mining", ...}
that scenarios (scripts/drive.py) use for views. The player: every section unlocked,
credits, resources and refined items granted, two moons, several tools (some drilling,
one ready, one idle with an attachment), a refinery batch running, an advisor hired.
one ready, one idle with upgraded tracks), a refinery batch running, an advisor hired.
--plain: just a new player (nothing granted), for the first-moon views.
"""
import json
@@ -66,8 +66,8 @@ admin("credits", amount=500000, op="grant")
for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance",
"Dusk Alloy", "Cryo Cell", "Plasma Weave"]:
admin("resources", resource=r, amount=400)
for item, tier in [(1, 0), (1, 0), (2, 1), (3, 0), (5, 2)]:
admin("tool", item=item, tier=tier)
for item, yl, cl in [(1, 0, 0), (1, 0, 0), (2, 0, 1), (3, 0, 0), (5, 2, 1)]:
admin("tool", item=item, **{"yield": yl, "cycle": cl})
act("buy-moon")
d = act("buy-moon")
@@ -89,11 +89,11 @@ act("stake", tool=long_tool, target=wells[0]["id"])
act("stake", tool=by_item[2][0]["id"], target=wells[1 % len(wells)]["id"])
act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"])
act("buy-attachment", item="Turbo Core")
act("buy-attachment", item="Diamond Bit")
act("buy-attachment", item="Diamond Bit")
# The idle PD-1 a level up on each track (its yield track twice).
idle = by_item[1][1]["id"]
act("equip", item="Turbo Core", to=idle)
act("upgrade", tool=idle, track="yield")
act("upgrade", tool=idle, track="yield")
act("upgrade", tool=idle, track="cycle")
act("buy-slot")
act("refine", recipe="Dusk Alloy", qty=5)
+11 -8
View File
@@ -1,8 +1,8 @@
"""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, tiers) without the Store's parts (upgrade,
slots, spares, Sell drill); after the first claim and sale reveal the Store, the same panel
has them. Shots "before" and "after", each linted."""
Equipment, a tool card 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
import sys
@@ -41,10 +41,10 @@ def run(game, args):
game.shot("before")
game.lint("before")
texts = labels(game)
game.expect("tiers" in texts, "tier ladder shown")
for t in ("slots", "spare attachments", "sell drill"):
game.expect(t not in texts, "%s hidden before the Store" % t)
game.expect(not any(t.startswith("upgrade to") for t in texts), "no upgrade before the Store")
game.expect("yield per cycle" in texts and "level" in texts, "track ladders shown")
game.expect("sell drill" not in texts, "sell drill hidden before the Store")
ups = [e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?")]
game.expect(not ups, "no upgrade before the Store: %s" % ups)
# First claim (Market), first sale (Store).
admin(game, "finish-stakes")
@@ -60,5 +60,8 @@ def run(game, args):
game.shot("after")
game.lint("after")
texts = labels(game)
for t in ("tiers", "slots", "spare attachments", "sell drill"):
for t in ("yield per cycle", "sell drill"):
game.expect(t in texts, "%s shown after the Store" % t)
ups = {e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?")}
for kind in ("yield", "cycle"):
game.expect(any(("track=" + kind) in i for i in ups), "%s upgrade after the Store: %s" % (kind, ups))
+9 -16
View File
@@ -2,7 +2,7 @@
scenario may change: MM_DB, default mm_c_lists, in the podman container MM_DB_CONTAINER,
default moonminers_db_1). Shots: a depleted moon row (and hovered), the mined-out
collapse and the Past tab flash, the greyed Past list, a new moon row entering, a new
drill entering (wells table and well panel), deploy cards short and spoken for."""
drill entering (well card and well panel), the deploy slot short and with no room."""
import json
import os
import subprocess
@@ -98,9 +98,9 @@ def run(game, args):
burst(game, "newmoon", [0.05, 0.3, 0.6, 1.2])
game.idle()
# Deploy cards: tools idle after claiming moon 1; a well with little left: a stake
# from the well panel (the new drill enters in the table and the panel), then the
# other cards come up short or spoken for.
# Deploying: tools idle after claiming moon 1; a well with little left: a deploy from
# its card's slot (the new drill enters in the card and the well panel), then the slot
# comes up short ("Only +N left here"), then "No room here" once nothing is left.
game.navigate("{section='moons', moon='%s'}" % fx["moon"])
game.idle()
if game.eval("st.ready") > 0:
@@ -112,24 +112,17 @@ def run(game, args):
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
game.idle()
game.shot("deploy")
# (ids from the state: the elements reply can be long)
def card(i):
tool = game.eval("st.well.idle[%d] and st.well.idle[%d].toolId" % (i, i))
return tool and "act:stake?target=%s&tool=%s" % (well, tool)
first = card(1)
game.expect(first, "deploy cards are there")
game.scroll_to(first) # (the cards and the drills list under them in view)
slot = "act:stake?target=%s" % well
first = next((e["id"] for e in game.elements() if e["id"].startswith(slot)), None)
game.expect(first, "the well's deploy slot is there")
if first:
game.scroll_to(first)
game.click(first)
burst(game, "newdrill", [0.05, 0.3, 0.6, 1.2])
game.idle()
game.shot("deploy-short", settle=0.5)
# Nothing left after the drills: spoken for (dimmed; less so under the mouse).
# Nothing left after the drills: no room.
psql("update resource_well set supply = 2 where id = '%s'" % well)
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
game.idle()
game.shot("deploy-spoken", settle=0.5)
n = game.eval("#st.well.idle")
if n:
game.hover(card(n))
game.shot("deploy-hover", settle=0.4)
+8 -8
View File
@@ -1,8 +1,8 @@
"""The moon view and well panel in the web's look (needs --fixture and a local dev server
whose database the scenario may change: MM_DB, see lists.py): the wells table (stacked
drill bars, Manage on every row, the rating chip), drills that get nothing (amber dashed
bars, the NOTHING LEFT tag, a greyed picture), a tapped-out well's panel, and the moon
sale opened (its lines scroll with the moon). Args: an optional name prefix."""
"""The moon view and well panel (needs --fixture and a local dev server whose database the
scenario may change: MM_DB, see lists.py): the well cards (a card per well, the rating
chip), drills that get nothing (amber dashed bars on their slots; in the aside the NOTHING
LEFT tag, a greyed picture), a tapped-out well, and the moon sale opened (its lines scroll
with the moon). Args: an optional name prefix."""
import os
import sys
@@ -19,13 +19,13 @@ def run(game, args):
game.idle()
game.shot(pre + "table", settle=0.8)
ids = [e["id"] for e in game.elements()]
wells = [w for w in fx["wells"] if ("manage:" + w) in ids]
game.expect(len(wells) == len(fx["wells"]), "Manage on every well row (%d of %d)" % (len(wells), len(fx["wells"])))
wells = [w for w in fx["wells"] if ("well:" + w) in ids]
game.expect(len(wells) == len(fx["wells"]), "a card for every well (%d of %d)" % (len(wells), len(fx["wells"])))
# Drills that get nothing: six more on a well, then the well holds almost nothing.
well = fx["wells"][1 % len(fx["wells"])]
for _ in range(6):
admin(game, "tool", item=2, tier=0)
admin(game, "tool", item=2, **{"yield": 0, "cycle": 0})
st = call(game, "GET", "/api/state")["state"]
idle = [t["id"] for t in st["tools"] if t["storeItemId"] == 2 and not t.get("stakeId")][:6]
for tid in idle:
+7 -7
View File
@@ -1,13 +1,13 @@
"""Parity polish (needs --fixture). Phases (args, default all but ready):
bars: six more drills on a well: the wells table's bars with "+N" and the multi-well
diamond caps, the well panel in the web's order (deploy, drills, then the claims).
bars: six more drills on a well: their slots on the well's card, the multi-well card,
the well panel's drill lines (it only shows: the actions are on the cards).
ready: run with --config mm.fake_latency_ms=2500: drills finish while the refetch is
held back; the header badge, Claim all, moon row badge and Claim buttons count up on
our clock ("Claim N drills", "Claim <amount>").
motion: the header's reduced-motion toggle (run with --config mm.reduced_motion=0: forced,
so nothing is saved), the still ticker, a reduced reveal.
links: "N idle <tool>" opens that copy, "Better" well link, market card hover, refinery
recipes."""
links: "N idle <tool>" opens that copy, market card hover, refinery recipes (the
"Better" well is plain text in the well panel now: its link shot is skipped)."""
import json
import time
import urllib.request
@@ -47,7 +47,7 @@ def idle_tools(game, item):
def stake_many(game, item, n, well):
for _ in range(n):
admin(game, "tool", item=item, tier=0)
admin(game, "tool", item=item, **{"yield": 0, "cycle": 0})
for tid in idle_tools(game, item)[:n]:
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
@@ -84,7 +84,7 @@ def ready(game):
free = [w]
well = free[0]["id"]
for _ in range(2):
admin(game, "tool", item=3, tier=0)
admin(game, "tool", item=3, **{"yield": 0, "cycle": 0})
copies = idle_tools(game, 3)[:2]
for tid in copies:
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
@@ -143,7 +143,7 @@ def links(game):
fx = game.fixture
# Two LunarLite L100 (10 s cycles), one mining: its panel links to the idle one.
for _ in range(2):
admin(game, "tool", item=3, tier=0)
admin(game, "tool", item=3, **{"yield": 0, "cycle": 0})
copies = idle_tools(game, 3)
r = call(game, "POST", "/api/act/stake", {"tool": copies[0], "target": fx["wells"][0]})
game.expect(r.get("ok"), "stake L100: %s" % r.get("error"))
+4 -15
View File
@@ -1,9 +1,8 @@
"""Sells ask in place, not in a dialog (needs --fixture; cards.ask_button): the drill
panel's spare attachment Sell turns into Cancel on its spot with Confirm just left of it and
panel's Sell (an idle copy) turns into Cancel on its spot with Confirm just left of it and
the server's question where the text was; no dialog opens; nothing else moves. It cancels
on Esc, on Cancel, on a click elsewhere and after ASK_IDLE seconds; Enter confirms (Confirm
has the soft focus) and sells. Then the drill's own Sell (asks, cancelled) and the moon's
Sell moon (shot of its asking state, when the moon can be sold). Shots of each."""
has the soft focus) and sells the drill. Then the moon's Sell moon (shot of its asking state, when the moon can be sold). Shots of each."""
def rect_of(game, id_):
@@ -49,9 +48,9 @@ def covers(outer, inner):
def run(game, args):
fx = game.fixture
pre = (args[0] + "-") if args else ""
sell = "confirm:sell-attachment"
sell = "confirm:sell-tool"
game.idle()
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.idle()
game.scroll_to(sell)
before = rect_of(game, sell)
@@ -95,16 +94,6 @@ def run(game, args):
game.expect(game.log_has(r"mm: toast Sold"), "Enter sells (a toast)")
game.shot(pre + "sellask-sold", settle=0.4)
# The drill's own Sell: asks, a shot, cancelled.
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.idle()
if rect_of(game, "confirm:sell-tool"):
ask(game, "confirm:sell-tool")
game.shot(pre + "sellask-tool", settle=0.3)
game.key("key_esc")
game.wait_for("g.ask == nil", 5)
game.idle()
# The moon's Sell moon (when it can be sold yet).
game.navigate("{section='moons', moon='%s'}" % fx["moon2"])
game.idle()
+7 -2
View File
@@ -1,6 +1,6 @@
"""Narrow windows (run with --size 1024x640; needs --fixture): the aside gets its own row
under the moon list and the main panel and the page scrolls to it (a well, the drill
panel); sections without the aside use the whole width; the banner wraps; keys scroll
under the moon list and the main panel and the page scrolls to it (the drill panel; a
well's card stays in view); sections without the aside use the whole width; the banner wraps; keys scroll
the page. Args: an optional name prefix."""
@@ -18,6 +18,11 @@ def run(game, args):
game.click("well:" + well)
game.wait_for("view.well == '%s' and not busy" % well)
game.idle()
# (picking a well card keeps the cards in view: its actions are there)
game.expect((game.eval("g.scroll_pos.page or 0") or 0) < 1, "picking a well doesn't scroll the page")
game.shot(pre + "small-well-card", settle=0.8)
game.hover("tab:market")
game.key("key_end")
game.wait_for("(g.scroll_pos.page or 0) > 100", timeout=5)
game.shot(pre + "small-well", settle=1.0)
# (keys scroll the area under the mouse: the moon itself scrolls when it doesn't fit,
+3 -3
View File
@@ -1,4 +1,4 @@
"""Server texts in the confirms (needs --fixture): selling a spare attachment asks in place
"""Server texts in the confirms (needs --fixture): selling an idle drill asks in place
(cards.ask_button), buy seat, buy refinery slot and start a new game open the server's
confirm dialog (each cancelled with Esc, nothing bought); the theme toggle switches live.
Shots of each."""
@@ -44,9 +44,9 @@ def run(game, args):
fx = game.fixture
pre = (args[0] + "-") if args else ""
game.idle()
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.idle()
ask(game, "confirm:sell-attachment", pre + "texts-sell-attachment")
ask(game, "confirm:sell-tool", pre + "texts-sell-tool")
game.navigate("{section='advisors'}")
game.idle()
confirm(game, "confirm:buy-seat", pre + "texts-buy-seat")
+34
View File
@@ -0,0 +1,34 @@
"""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 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."""
def ups(game):
return sorted(e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?"))
def run(game, args):
fx = game.fixture
pre = (args[0] + "-") if args else ""
game.idle()
game.navigate("{section='equipment'}")
game.idle()
game.shot(pre + "tracks-equipment", settle=1.0)
game.lint(pre + "tracks-equipment")
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.idle()
game.shot(pre + "tracks-panel", settle=1.0)
game.lint(pre + "tracks-panel")
tool = game.eval("g.state.equipment.drill.toolId")
for kind in ("yield", "cycle"):
ids = [i for i in ups(game) if ("tool=" + tool) in i and i.endswith("track=" + kind)]
game.expect(ids, "%s upgrade button in the panel: %s" % (kind, ups(game)))
for n in range(2):
ids = [i for i in ups(game) if ("tool=" + tool) in i and i.endswith("track=" + kind)]
if not ids:
break # (the top level: Max level in its place)
game.click_check(ids[-1], name=pre + "tracks-click-%s-%d" % (kind, n))
game.shot(pre + "tracks-panel-after", settle=1.0)
+121
View File
@@ -0,0 +1,121 @@
"""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
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).
Args: an optional name prefix."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from lists import admin, call # noqa: E402
def aside_buttons(game):
"""Registered elements drawn inside the aside panel (below the header)."""
box = game.eval("(function() local p = g.aside_panel and g.aside_panel.root if not p then return nil end "
"local s = gui.get_screen_position(p) return {s.x, s.y} end)()")
if not box:
return []
left, top = box[0], box[1]
out = []
for e in game.elements():
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:
out.append(e["id"])
return out
def ids(game, prefix):
return [e["id"] for e in game.elements() if e["id"].startswith(prefix) and e.get("visible", True)]
def run(game, args):
pre = (args[0] + "-") if args else ""
game.idle()
admin(game, "credits", amount=100000, op="grant")
had = {m["id"] for m in call(game, "GET", "/api/state?catalog=0")["state"]["moons"]}
r = call(game, "POST", "/api/act/buy-moon", {})
game.expect(r.get("ok"), "buy a moon: %s" % r.get("error"))
st = call(game, "GET", "/api/state?catalog=0")["state"]
moon = next(m for m in st["moons"] if m["id"] not in had)
wells = [w["id"] for w in moon["wells"]]
game.navigate("{section='moons', moon='%s'}" % moon["id"])
game.idle()
game.wait_for("st.moon and st.moon.id == '%s'" % moon["id"], 10)
game.idle()
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)
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")
game.idle()
# A multi-well drill (bought when none is idle).
st = call(game, "GET", "/api/state")["state"]
wide = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) > 1]
if not wide:
item = next((t for t in st["catalog"]["tools"] if (t.get("reach") or 1) > 1), None)
if item:
call(game, "POST", "/api/act/buy-tool", {"id": str(item["storeItemId"])})
st = call(game, "GET", "/api/state?catalog=0")["state"]
wide = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) > 1]
if wide:
r = call(game, "POST", "/api/act/stake", {"tool": wide[0]["id"], "target": wells[-1]})
game.expect(r.get("ok"), "stake a multi-well drill: %s" % r.get("error"))
# One more one-well drill on another well, so a card has two drill slots.
one = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) == 1]
if one and len(wells) > 1:
call(game, "POST", "/api/act/stake", {"tool": one[0]["id"], "target": wells[1]})
game.navigate("{section='moons', moon='%s', well='%s'}" % (moon["id"], wells[0]))
game.idle()
game.shot(pre + "cards-mining", settle=0.8)
game.lint(pre + "cards-mining")
left = aside_buttons(game)
game.expect(not left, "the aside (a well) has no buttons: %s" % left)
game.shot(pre + "aside-well", settle=0.3)
# Finish them: ready slots.
admin(game, "finish-stakes")
game.navigate("{section='moons', moon='%s'}" % moon["id"])
game.idle()
game.wait_for("(function() for _, w in ipairs(st.moon.wells) do if w.ready > 0 then return true end end end)()", 15)
game.idle()
game.shot(pre + "cards-ready", settle=0.8)
game.lint(pre + "cards-ready")
claims = [i for i in ids(game, "act:claim?claim-well=" + wells[0]) if "redeploy" not in i]
game.expect(len(claims) > 0, "a ready slot has Claim")
game.click_check(claims[0], name=pre + "click-claim")
game.wait_for("not g.busy and #g.pending == 0", 20)
game.idle()
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)
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.shot(pre + "cards-redrilled", settle=0.8)
left = aside_buttons(game)
game.expect(not left, "the aside (the moon) has no buttons: %s" % left)
game.shot(pre + "aside-moon", settle=0.3)
+2 -2
View File
@@ -1,5 +1,5 @@
"""The well panel scrolled down (needs --fixture): deploy cards with pictures, pips and
reach, drill lines with thumbnails and attachment tags, the drills heading's share note;
"""The well panel scrolled down (needs --fixture; it only shows, deploying is on the well
cards): drill lines with thumbnails and level tags, the drills heading's share note;
then the market (movers, quote values, Refined tag) and a past moon in the list."""
-1
View File
@@ -16,4 +16,3 @@ sql/queries.sql
# Generated by the art scripts
/art/equipment/build/
/art/advisors/build/
/art/attachments/build/
+23 -22
View File
@@ -49,7 +49,7 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
for every test view.
- `config/game.json`: the game config (starting credits/resources/tools, `moonGrades`,
resource prices, store items with upgrade tiers, moon types: a name, a placeholder
resource prices, store items with their two upgrade tracks, moon types: a name, a placeholder
description and resource `affinities`, refinery), embedded. `moonGrades` (D, C, B, A, S,
cheapest first) are moon vendors: a price, `wellCountWeights`, a well supply range and
affinities that multiply the type's. The grade is `staking_source.grade` (migration 018;
@@ -58,11 +58,11 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
count by its grade's weights, resources by the affinities, each well's supply in the
grade's [`wellSupplyMin`, `wellSupplyMax`); a first moon always has every raw input of a
recipe. Ratings, reveal bars, resale and descriptions compare a moon with its grade.
Upgrade tiers set a cycle `speed` (read from cfg(), not stored; `StoreItem.ToolSecs`). A moon's name comes from `moonNames` (roots and
Track levels set a tool's yield and cycle (read from cfg(), not stored; `StoreItem.ToolYield`, `ToolSecs`). A moon's name comes from `moonNames` (roots and
suffix style weights, `rollMoonName`), never repeated among a player's moons; its type is
`staking_source.kind` (moons from before migration 013 were named after their type).
`config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item`
(needed for the inventory foreign key and the SQL in game.go). Admins override its
(needed for the inventory foreign key and the SQL in game.go; the tracks aren't stored). Admins override its
values live on `/utils/economy` (table editor in `economy.js`; `economy.go`: a patch per
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim). Read config values through
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
@@ -87,25 +87,27 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
at `maxRatio` × the replacement moon's price (`moonPriceFor(owned-1)`), which it's
compared with. Not the last moon, not within `minHoldSecs` of buying it, not while a
drill on it is running; finished drills are claimed in the sale. Template: `moon-sale`.
Also selling tools and spare attachments for `equipmentResale.factor` (below 1) of their
credit price (a tool: price + its upgrades; refined parts not refunded): only idle
tools with nothing to claim, never the last tool; attachments on it return to the inventory.
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on upgrade tiers), rules
Also selling tools for `equipmentResale.factor` (below 1) of their credit price (price +
its track levels; refined parts not refunded): only idle tools with nothing to claim,
never the last tool.
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on track levels), rules
(start batch, collect, buy slot, batch size upgrade, sell refined, upgrade parts) and its
view model. A batch of n takes `seconds` × min(n, 1 + `bulkLog` × ln n) (`batchSecs`): logarithmic. The most per
batch is `maxBatch`, raised for the whole refinery (every slot) by `batchUpgrades`
(action `upgrade-batch`, `users.refinery_batch_level`). Tables
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
- `bonus.go`: player bonuses. `bonusesFor` combines advisors and a tool's attachments
(multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (used by
- `bonus.go`: player bonuses. `bonusesFor` combines the seated advisors (multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (cycles to a tenth of a second, at least 1 s) (used by
`stakeInTx`, the views and the sim); sells and refinery batches use `Bonus.Sell`/`Refine`.
Bonuses are fixed when the action runs (a stake keeps its duration and amount).
- `attachments.go`: attachments config (`attachments` in game.json: `slotsPerTool`, items
with `speed`/`yield` multipliers), buy/equip/unequip rules (one of each per tool; equip
and unequip any time, they count from the next stake), store cards and the drill panel
(`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped).
- `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`,
`tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds
flat, with a price and, higher up, refined `parts`. Levels are per tool
(`inventory_item.yield_level`, `cycle_level`; copies with the same levels are
interchangeable, `pickTool`); action `upgrade` (`track=yield|cycle`), counted from the next
stake. Reach is the item's and never changes. Also the view models (`TrackRow` with its
ladder, on the Equipment card and in the drill panel: `drill=` in the URL, Equipment
section, in the aside; the toast text).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
@@ -114,8 +116,8 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
`all` for players from before it). New players see only the first moon purchase; deploying
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
attachments, moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
tool Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
sections and actions need a gate there.
@@ -152,9 +154,8 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `scripts/tool-art.mjs`: generated tool art per tier, not in use (its output was removed;
the tools show their original images). `StoreItem.ImageAt` uses `<image>-tN` from
`web/static/assets/items` when it exists, else the base image.
- `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in
use; the tools show their original images.
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `web/app/` (paused experiment: the Preact client, `GET /app`, template `spa.html`): the
Moons slice on the JSON API; other sections link to the classic client. `session.js` is
@@ -164,10 +165,10 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
effects (`scripts/check-signals-jsx.mjs` in `make check`). Key list items by stable ids
(drills by tool id, not stake id); never swap an element that animates for another.
Animate with transform/opacity (CSS, Web Animations), FLIP (`flip.js`) for rows that move.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–019):
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–020):
changes for existing ones, applied on every `migrate`, so each must be idempotent.
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
- `art/`: sources of the store, attachment and advisor images (Blender scripts, prompts);
- `art/`: sources of the store and advisor images (Blender scripts, prompts);
they write into `web/static/assets`.
## Commands
+54
View File
@@ -1,3 +1,57 @@
# 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
before the next grade, cheaper than that grade's moons) empties the current grade's wells
in about one claim; on the next grade it is back to 3–6. Each drill's base yield is about
its grade's average well ÷ 4.5 (multi-well drills × their reach): PD-1 8 (D), PX-4 60 (C),
L100 300 (B), L300 4,000 (A), LRE 48,000 (S); EDE 110,000 and LCE 200,000 rip S further
(more drills than grades until planets and stars). Sim, claims per average well on its own
grade: PD-1 4.3, PX-4 5.4, L100 4.5, L300 4.5, LRE 4.6, EDE 3.3, LCE 1.8.
Prices: each drill earns a bit more per credit than the one before on its grade (sim "a
drill alone on its grade", ₥/min per 1k₥: 981, 1.6k, 2k, 2.4k, 2.6k, 2.9k, 3.1k), else the
sim (and a player) just buys more of the cheapest drill: PD-1 1,200, PX-4 4,000, L100
13,400, L300 129,000, LRE 1.18M, EDE 2.12M, LCE 3.1M. Each upgrade level costs 0.95 × what
a copy would cost for the same added output (so upgrades are slightly better per credit).
Sim, first moon of each grade: copies C 7m, B 15m, A 32m, S 1h04; boosts C 6m, B 14m,
A 31m, S 49m (was about C 13m, B 22m, A 1h10, S 3h). Faster overall; the lever for a longer
game is moon and drill prices with wells scaled alike.
# Drill upgrade tracks (2026-10-22)
Attachments and the T1–T5 tier ladder are gone. Each drill has two tracks of its own
(config `storeItems[].tracks`), 4–5 levels each, every level a flat value: yield (claim per
cycle) and cycle (seconds, to a tenth). Reach never changes. Levels 3+ need refined parts.
Prices: level k of yield costs 0.97 × drill price × (Δclaim / base claim) × (base cycle /
cycle at level k−1), cycle level k costs 0.97 × price × (claim at level k−1 / base) × Δ(base
cycle / cycle), × 0.95^(k−1), minus the parts' value, two significant digits, never below
the level before. So with both tracks moving together a level adds output for about a
copy's price.
| drill | yield track | cycle track |
|---|---|---|
| PD-1 | Twin rotors 4 → 5, 6, 7, 8 | Flight computer 3 → 2.6, 2.2, 1.9, 1.7, 1.5 s |
| PX-4 | Wider chisel 20 → 24 … 40 | Pneumatic hammer 6 → 5.2, 4.5, 3.9, 3.4, 3 s |
| L100 | Wide-bore bit 110 → 130 … 220 | Spindle motor 8 → 7, 6, 5.2, 4.6, 4 s |
| L300 | Wider beam 1350 → 1600 … 2700 | Laser capacitors 10 → 8.7 … 5 s |
| LRE | Fine sieves 17k → 20k … 34k | Auger gearbox 12 → 10.4 … 6 s |
| EDE | Larger plates 36k → 43k … 72k | Charge coils 14 → 12.2 … 7 s |
| LCE | Core barrel 47k → 56k … 94k | Drill string 16 → 14 … 8 s |
Per ₥ against a copy (a drill alone on its grade): the first yield level ×1.02–1.05 (₥/min),
×1.04–1.09 (₥/click); whole yield track ×0.79–0.88; first cycle level ×0.94–1.01 (₥/click
×0.28–0.67: shorter cycles cost claims); both tracks to the top ×1.12–1.19. PD-1's cycle
levels add nothing in the sim: its player claims at most every 3 s (`gsClaimGap`).
| first moon of each grade | before (tiers, attachments) | after (tracks) |
|---|---|---|
| copies | C 13m15, B 22m26, A 1h10m37, S 2h59m52 | the same |
| boosts | C 13m15, B 22m26, A 1h08m39, S 3h00m21 (bought only copies and 2 advisors) | C 13m18, B 23m39, A 1h05m50, S 3h01m59 (233 copies, 234 levels, 2 advisors) |
At 0.85 (levels ~15% better than a copy) the boosts player reached A at 54 m and S at
2h26; at 1.0 and above it bought almost no levels.
# Balance notes: moon grades, clicker pacing (2026-10-21)
Moon Miners is a clicker: the player is always clicking. `go run . sim` prints the grade
+13 -11
View File
@@ -4,7 +4,7 @@ A browser mining game. Players own moons (staking sources) with resource wells,
upgrade mining tools, stake them on wells, claim resources when the stake completes, and sell
resources for credits (₥). Drill cycles last seconds, so play is active. The refinery turns
resources into refined items on a timer; those sell for more and are needed as parts for
higher tool upgrades.
the higher levels of the drills' upgrade tracks.
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
a three.js view of the selected moon, generated procedurally from its id. Game rules run in
@@ -93,17 +93,21 @@ same schema (compare `pg_dump --schema-only`). The migrations so far: 001 drops
tables, 002 moon settings, 003 config overrides, 004 live ops, 005 refinery, 006 player
admin, 007 drill cycles in seconds, 008 security fixes (UTC defaults, one claim per stake,
non-negative well supply), 009 schema cleanup (old columns, moon images), 010 drill
attachments and advisors, 011 market pressure, 012 onboarding, 013 moon type
attachments (dropped in 020) and advisors, 011 market pressure, 012 onboarding, 013 moon type
(`staking_source.kind`, now that moons get generated names), 014 drill reach, 015 moon
rating, 016 advisor seats, 017 save codes, 018 moon grade, 019 refinery batch size
(`users.refinery_batch_level`).
(`users.refinery_batch_level`), 020 drill upgrade tracks (`inventory_item.yield_level`,
`cycle_level`; old tiers became cycle levels, attachments were refunded and dropped,
`upgrade_item` dropped, stake cycles in tenths of a second).
## Game config
`config/game.json` holds the balance numbers (starting credits, resources and tools, moon
price, well supply range, `wellCountWeights`: how many wells a new moon gets, resource prices
in credits per unit), the store items with their
upgrade tiers (tiers can also need refined `parts`; a drill cycle is `completionTimeInSecs`),
two upgrade tracks (`tracks.yield` and `tracks.cycle`: a name and levels, each setting the
claim per cycle or the cycle in seconds flat, with a `price` and, higher up, refined `parts`;
a drill's base cycle is `completionTimeInSecs`, its reach never changes),
the moon types (a name, a placeholder description: the look is procedural and the
description is generated when the moon is bought, see `moondesc.go`, and `affinities`:
weights of the resources its wells hold, 1 if unlisted), moon names (`moonNames`: roots, and
@@ -117,11 +121,9 @@ function of name and time (`market.go`), which also gives the 24h charts; sells
A player's own selling lowers their price a little (`marketPressure`): each sale adds its
base value to their pressure on the item, which halves every `halfLifeSecs`, and the price
factor is `floor + (1 − floor) × e^(−pressure / scale)`, so it flattens out at `floor`.
Drill attachments (`attachments`: `slotsPerTool`, and per attachment a `price`, an `image`
and `speed`/`yield` multipliers) are bought in the Store and equipped on a drill from its
panel in Equipment, where idle drills and spare attachments can also be sold back for
`equipmentResale.factor` of their credit price (a drill: its price plus its upgrades; refined
parts aren't refunded). A moon sells back (`moonResale`) for what's left in its wells at market
Each drill's tracks are upgraded per tool, one level at a time, on its Equipment card or
in its panel, where idle drills can also be sold back for `equipmentResale.factor` of their
credit price (its price plus its track levels; refined parts aren't refunded). A moon sells back (`moonResale`) for what's left in its wells at market
price × `factor`, at most `maxRatio` × the price of a replacement moon, no sooner than
`minHoldSecs` after buying it, and not while a drill on it is running. Advisors (`advisors`: `seatPrices` for the 2nd, 3rd, ... seat, and per
advisor a hire `price` and `speed`/`yield`/`sell`/`refine` multipliers) boost the whole
@@ -147,7 +149,7 @@ shares one nav: Players · Moons · Economy · Live ops · Game.
| `/utils/players` | Find players (id prefix or name), sort by last seen, activity, newest, richest. |
| `/utils/players/{id}` | One player: balance, resources, refined items, moons and wells, tools and drills, event history (older pages at `/utils/players/{id}/events?page=n`). Grant/remove credits, grant resources and tools, finish running drills, reset, ban/unban. |
| `/utils/moons` | Moon lab: live 3D preview with sliders for terrain, craters, colors, bloom, atmosphere and rotation. "Save for the game" applies the look to every moon. |
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with upgrade tiers), resources, refined items, market, attachments, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with its yield and cycle tracks), resources, refined items, market, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
| `/utils/liveops` | Dashboard, timed events and announcements. |
Actions (all `POST`, confirmed in the page's own dialog):
@@ -177,7 +179,7 @@ edits the shared settings that every moon starts from; they're stored as one JSO
### Economy
A form for the constants, resource prices, store items and upgrade tiers (including parts),
A form for the constants, resource prices, store items and their upgrade tracks (including parts),
and the refinery, plus a raw JSON editor for every field. The analysis updates as you type:
income and payback per tool and upgrade, refining gain per recipe, red flags, and a 72-hour
greedy-player sim. Each save stores the values that differ from `config/game.json` as a new row
+2 -2
View File
@@ -1,13 +1,13 @@
package main
// Advisors: people a player hires once, for credits, who boost the whole operation (drill
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments).
// speed, yield, sell prices, refinery speed; bonus.go combines them).
// Seats are numbered: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Hiring needs a free seat and seats the advisor in the lowest one. Only
// seated advisors give their bonus. A hired advisor can be unseated (kept on the payroll,
// no bonus), seated again in a free seat, swapped with another (seated or not) or
// dismissed. Dismissing refunds nothing (the price is a hiring fee, like seats and slots,
// which aren't refunded either); hiring them again costs the full price. Like attachments,
// which aren't refunded either); hiring them again costs the full price. Like every bonus,
// changes count from the next action: running drills keep their bonus.
// Tables advisor_hire (seat null: not seated) and users.advisor_seats.
+30 -39
View File
@@ -132,18 +132,17 @@ type APIMoonListItem struct {
}
type APITool struct {
ID string `json:"id"`
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // model code, e.g. "PD-1"
Image string `json:"image"` // static URL
Tier int `json:"tier"`
MaxTier int `json:"maxTier"`
Reach int `json:"reach"` // wells one cycle mines
CycleSecs int `json:"cycleSecs"` // a stake now, with bonuses and live events
Yield float64 `json:"yield"`
Attachments []string `json:"attachments"`
StakeID string `json:"stakeId,omitempty"` // mining, if set
ID string `json:"id"`
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // model code, e.g. "PD-1"
Image string `json:"image"` // static URL
YieldLevel int `json:"yieldLevel"` // levels on its upgrade tracks (0: base)
CycleLevel int `json:"cycleLevel"`
Reach int `json:"reach"` // wells one cycle mines
CycleSecs float64 `json:"cycleSecs"` // a stake now, with bonuses and live events (tenths)
Yield float64 `json:"yield"`
StakeID string `json:"stakeId,omitempty"` // mining, if set
}
type APIStake struct {
@@ -156,7 +155,7 @@ type APIStake struct {
Reach int `json:"reach"`
Start int64 `json:"start"`
End int64 `json:"end"`
Secs int `json:"durationSecs"`
Secs float64 `json:"durationSecs"`
Ready bool `json:"ready"`
Take float64 `json:"take"` // what claiming it would get now
// The web's words for its reach: "Mines 3 wells", "Strips the whole moon", "" for one.
@@ -167,7 +166,7 @@ type APIStake struct {
Gets *float64 `json:"gets,omitempty"`
Here *float64 `json:"here,omitempty"`
State string `json:"state,omitempty"`
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 T0: +2"
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 Y1 C2: +2"
}
type APIDrillGroup struct {
@@ -228,19 +227,19 @@ type APIWell struct {
}
type APIToolGroup struct {
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Tier int `json:"tier"`
Count int `json:"count"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
Free float64 `json:"free"`
Short bool `json:"short"`
Blocked bool `json:"blocked"` // here is 0: the stake is refused
Attachments []string `json:"attachments"`
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
YieldLevel int `json:"yieldLevel"`
CycleLevel int `json:"cycleLevel"`
Count int `json:"count"`
CycleSecs float64 `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
Free float64 `json:"free"`
Short bool `json:"short"`
Blocked bool `json:"blocked"` // here is 0: the stake is refused
}
type APIBetterWell struct {
@@ -334,14 +333,6 @@ func apiAmounts(rows []ResourceBalance) []APIAmount {
return out
}
func apiGear(gear []*Attachment) []string {
out := []string{}
for _, a := range gear {
out = append(out, a.Name)
}
return out
}
func apiStake(st StakeRow, projected bool) APIStake {
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
@@ -415,8 +406,8 @@ func apiState(p *Page, extra ...*Page) *APIState {
continue
}
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
Image: itemImages + item.ImageAt(t.Tier), Tier: t.Tier, MaxTier: len(item.Upgrades),
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
Image: itemImages + item.Image, YieldLevel: t.YieldLevel, CycleLevel: t.CycleLevel,
Reach: item.ToolReach()}
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
if st := s.StakeForTool(t.ID); st != nil {
tool.StakeID = st.ID
@@ -453,8 +444,8 @@ func apiState(p *Page, extra ...*Page) *APIState {
well := &APIWell{ID: w.ID, Resource: w.Resource, Rate: w.Rate, Idle: []APIToolGroup{}}
for _, g := range w.Idle {
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
Tier: g.Tier, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield, Reach: max(1, g.Reach), Here: g.Here,
Free: g.Free, Short: g.Short(), Blocked: g.Blocked(), Attachments: apiGear(g.Gear)})
YieldLevel: g.YieldLevel, CycleLevel: g.CycleLevel, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield,
Reach: max(1, g.Reach), Here: g.Here, Free: g.Free, Short: g.Short(), Blocked: g.Blocked()})
}
if b := w.Better; b != nil {
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
+62 -67
View File
@@ -85,11 +85,8 @@ The other sections' actions:
| action | params | notes |
|---|---|---|
| `buy-tool` | `id` (store item id) | response has `buyModal` |
| `upgrade` | `tool` (tool id) | `drill=` follows the copy upgraded |
| `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`) |
| `buy-attachment` | `item` (attachment name) | |
| `equip`, `unequip` | `item` (attachment name), `to` (tool id) | `drill=` follows the copy used |
| `sell-attachment` | `item` | a spare one |
| `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 |
@@ -140,18 +137,18 @@ session; the web page also reads it once per page load.
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
| `pastPage` | `{page, pages, total, perPage}`: the page of past moons in `past` (`total` in all); ask for others with `page=` |
| `moons`, `past` | `past` is one page (`pastPage`). `[{id, name, kind, grade, maxSupply, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18"); `grade` its moon grade, `maxSupply` its grade's biggest well (a full supply bar) |
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL, the tool's art at its tier (`shovel-t2.png` at T2; tier 0 and tiers without art use the base image); `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
| `tools` | every tool: `{id, storeItemId, name, code, image, yieldLevel, cycleLevel, reach, cycleSecs, yield, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL; `yieldLevel`/`cycleLevel` its levels on its two upgrade tracks (0: the base tool); `cycleSecs`/`yield` at those levels with advisors and "all" live events; `cycleSecs` is in tenths of a second, e.g. `2.6`) |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, reachText, start, end, durationSecs, ready, take, covers?}`; `reachText` the web's "Mines 3 wells" / "Strips the whole moon" (`""` for one well) |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `fullyCommitted` (`remain` is 0 with supply left: a one-well drill can't be deployed on it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, blocked, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1: +2 of 3, ready"; a tool with upgrades has its levels after the name, "Prospector Drone PD-1 Y1 C2: …"); `label` is the web's compact name of the well's drills ("PD-1", "PD-1 Y1 C2", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `fullyCommitted` (`remain` is 0 with supply left: a one-well drill can't be deployed on it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, yieldLevel, cycleLevel, count, cycleSecs, yield, reach, here, free, short, blocked}], better: {moonId, wellId, resource, free, toolId}?}` |
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, unit, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is the shared quote (`rate` × `boost`, the same for every player; ticker, cards, `value` = balance × price, `total`); personal, on the payout only: `advisor` (sell bonus), `pressure` and its `factor` (the player's recent sales of the item), `unit` = price × advisor × factor (what one unit pays them now), `unpressed` = price × advisor, `sellAll` what selling the whole balance pays them (a sale of n pays n × unpressed × the pressure factor averaged over the n × base it adds, see `rules.pressure`); the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `boostText` "×2" when `boosted` (live event, on the quote), and the ticket's personal lines `advisorText` "+8%" when `advised` ("+8% for you") and `pressureText` "−4%" when `pressed` ("−4% from your recent selling") (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
| `banner` | `[{level, text, left, kind, id, end}]` announcements, then live events: `kind` `announcement` or `event`, `id` the announcement's or live event's id (an event's matches `liveEvents[].id`), `end` when it ends; `left` the web's "3h 20m left" at `now` (events only, else `""`) |
| `liveEvents` | active live events: `[{id, name, kind, resource, multiplier, start, end, headline}]`, `kind` `price_multiplier`, `cycle_multiplier` or `yield_multiplier` |
| `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
| `jobs` | once the refinery is open: every batch `[{id, slot, item, qty, start, end, ready}]` (the Refinery tab's badge) |
| `bonuses` | active advisor bonuses (the web's bonus chips): `[{kind, mult, capped, label, text}]`, `kind` `speed`, `yield`, `sell`, `refine`; `label` and `text` the chip ("Drill speed", "+10%") |
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, slotsPerTool, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkLog, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the selected moon's grade max, or its biggest well if bigger; with no moon selected the default grade's); `wellSupplyMax`, `wellsMin`, `wellsMax` are the default grade's (each moon carries its own `maxSupply`); `boosts` are live event multipliers by resource or `"all"` |
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, speed, cycleSecs, reach, parts: [{name, amount}], image}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`; a tool's `image` is its tier 0 art, each tier's `image` its art at that tier); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkLog, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the selected moon's grade max, or its biggest well if bigger; with no moon selected the default grade's); `wellSupplyMax`, `wellsMin`, `wellsMax` are the default grade's (each moon carries its own `maxSupply`); `boosts` are live event multipliers by resource or `"all"` |
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tracks: [{kind, name, levels: [{level, value, price, parts: [{name, amount}]}]}]}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`); a tool's `tracks` are its yield track then its cycle track, each level's `value` the claim per cycle or the cycle in seconds from that level on, before bonuses (`reach` never changes); an advisor's `effectText` e.g. "+8% sell prices"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon"; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
| `catalogVersion` | a hash of the catalog, for `catalog=` |
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
@@ -165,43 +162,43 @@ short code, listed with each field, and the web's text next to it.
### equipment (`s=equipment`)
```
{tools: [{id, storeItemId, name, code, image, tier, maxTier, count, idle, reach, cycleSecs, yield,
attachments, slots, freeSlots, selected, stake, next, preview, ladder, resale, noSale, bonus, gear}],
{tools: [{id, storeItemId, name, code, image, yieldLevel, cycleLevel, count, idle, reach, cycleSecs, yield,
selected, stake, tracks, resale, noSale, noSaleText, bonus}],
drill}
```
- `image`: the tool's art at its tier, like `state.tools`.
- Copies of a tool (same type, tier and attachments) are one row: `count` copies, `idle`
of them idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill.
- `next`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}],
canAfford, blocked, short, blockedText, shortText}`; `blocked` is `credits` or `parts`
(`""` when it can be bought), `short` the credits missing; `blockedText` the button's
tooltip ("Need ₥750 more", "Need 2 Dusk Alloy (you have 0). Make it in the Refinery"),
`shortText` the line under it when credits are short.
- `preview`: what the next tier changes, with the player's bonuses now: `{tier, yieldFrom,
yieldTo, secsFrom, secsTo, reachFrom, reachTo, rateFrom, rateTo, cost, paybackSecs}`
(`rate` is credits per hour; `paybackSecs` null if it never pays back).
- `ladder`: every tier `[{tier, cycleSecs, yield, reach, rate, current, next}]`.
- `resale`: what it sells for; `noSale`: `only-tool`, `claim-first`, `mining` or `""`,
`noSaleText` its text ("Claim its haul first").
- `bonus`: `{speed, yield}` from advisors and its attachments (capped); `gear` the
attachments `[{name, image, effect}]`.
- `drill` (with `drill=<tool id>`, once `unlocked.equipment`, else `null`): the drill panel `{toolId, slots: [{n,
attachment}], spares: [{name, count, cycleSecs, yield, canEquip, why, whyText, price, resale,
confirm}], mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo, ladder,
gear}`. `attachment` is `""` for an empty slot (unequip with `item=<name>&to=<toolId>`); a
spare's `cycleSecs`/`yield` are the drill's with it equipped, `why` is `fitted` (one is on
already) or `full`; `confirm` (panel) is the sell-tool dialog, a spare's the
sell-attachment one. `gear[].effectText` e.g. "+20% speed". While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy
and `deployTo` its best well (a suggestion, as in `buyModal`). Its upgrade, slots, spares
and Sell drill are Store features (`upgrade`, `equip`, `unequip`, `sell-tool` are refused
before it): show them once `unlocked.store`; before that the panel is its stats and `ladder`.
- 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.
- `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
with `tool=<id>&track=<kind>`.
- `next`: the next level, `null` at the top: `{level, value, label, price, parts: [{name,
need, have}], canAfford, blocked, short, blockedText, shortText, rateFrom, rateTo, cost,
paybackSecs}`; `label` the track name and the level it buys ("Twin rotors 2"); `blocked`
is `credits` or `parts` (`""` when it can be bought), `short` the credits missing;
`blockedText` why the button is disabled ("Need ₥750 more", "Need 2 Dusk Alloy (you have
0). Make it in the Refinery"), `shortText` the line under it when credits are short;
`rateFrom`/`rateTo` credits per hour now and after it, `cost` its price plus the parts at
sell value, `paybackSecs` null if it never pays back.
- `ladder`: every level from 0 `[{level, value, rate, price, parts: [{name, need, have}],
current, next}]` (the other track as it is; `price` 0 for level 0).
- `resale`: what it sells for (its price and its levels' prices × `rules.equipmentResale`);
`noSale`: `only-tool`, `claim-first`, `mining` or `""`, `noSaleText` its text ("Claim its
haul first").
- `bonus`: `{speed, yield}` from advisors (capped).
- `drill` (with `drill=<tool id>`, once `unlocked.equipment`, else `null`): the drill panel
`{toolId, mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo}`; its
tracks and ladders are the selected row's (`selected`). `confirm` is the sell-tool dialog.
While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy and
`deployTo` its best well (a suggestion, as in `buyModal`). Its upgrade buttons and Sell
drill are Store features (`upgrade` and `sell-tool` are refused before it): show them once
`unlocked.store`; before that the panel is its stats and the ladders.
### store (`s=store`)
```
{tools: [{storeItemId, name, code, image, description, price, canAfford, owned, yield, cycleSecs, reach, rate, bestRate, whyText}],
attachments: [{name, image, description, effect: {speed, yield}, effectText, price, canAfford, spare, equipped, resale, whyText}]}
{tools: [{storeItemId, name, code, image, description, price, canAfford, owned, yield, cycleSecs, reach, rate, bestRate, whyText}]}
```
`whyText`: the disabled Buy button's tooltip ("Need ₥1,400 more"), `""` when affordable.
@@ -324,8 +321,8 @@ is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkl
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
the web's title, question, OK button and whether it's destructive, e.g.
`{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 (T0) for ₥750? Its
attachments go back to your inventory.", "ok": "Sell for ₥750", "danger": true}`. Texts with
`{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok":
"Sell for ₥980", "danger": true}`. Texts with
a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.now`.
Not sent (the client words them from the numbers, like the web's templates):
@@ -333,12 +330,13 @@ Not sent (the client words them from the numbers, like the web's templates):
supply" (`over`).
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
current drills" (`well.idle[].short`, `here`, `free`).
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
- upgrade payback ("pays back in 2h 5m"), "Max level", empty states ("No idle tools.").
- a tool's levels after its name, " Y2 C1" (none for a base tool).
The web's formats:
- credits: `₥` + the amount with thousands commas and up to 2 decimals (`₥1,234.5`);
amounts the same without `₥`. Compact (per hour): `₥1.2M`, `₥45k`, `₥1.5k`, else whole.
- cycles: `3s`, `2m`, `2m 30s`; longer spans `2h 5m`. Countdowns `m:ss`, `h:mm:ss`.
- cycles: `3s`, `2.6s` (tenths under a minute), `2m`, `2m 30s`; longer spans `2h 5m`. Countdowns `m:ss`, `h:mm:ss`.
- banner time left: `2d 4h left`, `3h 20m left`, `45m left`.
- bonuses: `+` and the percent with one decimal at most (`+10%`, `+12.5%`).
@@ -412,16 +410,16 @@ Content-Type: application/json
"past": [],
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5,
"reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}],
"image": "/static/assets/items/shovel.png", "yieldLevel": 0, "cycleLevel": 0,
"reach": 1, "cycleSecs": 3, "yield": 4, "stakeId": "efc5071d-…"}],
"stakes": [{"id": "efc5071d-…", "moonId": "54daa782-…", "wellId": "b9d643c4-…", "toolId": "0fbfcb4f-…",
"resource": "Nebulance", "amount": 2, "reach": 1, "start": 1792391815259, "end": 1792391819259,
"durationSecs": 4, "ready": false, "take": 2}],
"moon": {"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "description": "A bleached mauve satellite …",
"rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70}, "finished": false, "ready": 0, "nextEnd": 1792391819259,
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47,
"drills": [{"id": "efc5071d-…", "…": "…", "gets": 2, "here": 2, "state": "ok", "fate": "Prospector Drone PD-1 T0: +2"}],
"ready": 0, "readyAmount": 0, "nextEnd": 1792391819259, "cap": 47, "label": "PD-1 T0", "wide": [],
"drills": [{"id": "efc5071d-…", "…": "…", "gets": 2, "here": 2, "state": "ok", "fate": "Prospector Drone PD-1: +2"}],
"ready": 0, "readyAmount": 0, "nextEnd": 1792391819259, "cap": 47, "label": "PD-1", "wide": [],
"selected": true, "redrill": 0, "demand": 2, "remain": 45, "dryAfter": 0, "dryAt": null}],
"wide": null},
"well": {"id": "b9d643c4-…", "resource": "Nebulance", "rate": 11.157, "idle": [], "better": null},
@@ -431,8 +429,8 @@ Content-Type: application/json
"selected": "Azurium", "total": 0},
"banner": [],
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"},
"catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "…": "…"},
"catalog": {"tools": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
"catalogVersion": "9adb4eabab929f5d"
}
}
@@ -445,20 +443,20 @@ From a live server (long ids and arrays shortened).
`GET /api/state?s=equipment&drill=7e40…`, `state.equipment`:
```json
{"tools": [{"id": "7e40…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel-t1.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true,
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0},
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1,
"rateFrom": 24015.57, "rateTo": 32020.76, "cost": 900, "paybackSecs": 404.7},
"ladder": [{"tier": 0, "cycleSecs": 3, "yield": 2, "reach": 1, "rate": 16010.38, "current": false, "next": false}, "…"],
"resale": 1175, "noSale": "", "bonus": {"speed": 1.2, "yield": 1},
"gear": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png", "effect": {"speed": 1.2, "yield": 1}}]}],
"drill": {"toolId": "7e40…", "slots": [{"n": 1, "attachment": "Turbo Core"}, {"n": 2, "attachment": ""}],
"spares": [{"name": "Diamond Bit", "count": 1, "cycleSecs": 3, "yield": 2.3, "canEquip": true, "why": "",
"price": 4000, "resale": 2000}],
"mining": false, "resale": 750, "noSale": "", "idleSame": 0, "idleBest": "", "deployTo": null,
"ladder": ["…"], "gear": ["…"]}}
{"tools": [{"id": "13b3…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "yieldLevel": 1, "cycleLevel": 2, "count": 1, "idle": 1,
"reach": 1, "cycleSecs": 2.2, "yield": 5, "selected": true, "stake": null,
"tracks": [{"kind": "yield", "name": "Twin rotors", "level": 1, "max": 4, "value": 5,
"next": {"level": 2, "value": 6, "label": "Twin rotors 2", "price": 320, "parts": [],
"canAfford": true, "blocked": "", "short": 0, "blockedText": "", "shortText": "",
"rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7},
"ladder": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false},
{"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"]},
{"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…", "ladder": ["…"]}],
"resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}],
"drill": {"toolId": "13b3…", "mining": false, "resale": 980, "noSale": "", "noSaleText": "",
"confirm": {"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok": "Sell for ₥980", "danger": true},
"idleSame": 0, "idleBest": "", "deployTo": null}}
```
`s=store`, `state.store`:
@@ -466,10 +464,7 @@ From a live server (long ids and arrays shortened).
```json
{"tools": [{"storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", "image": "/static/assets/items/shovel.png",
"description": "A hovering scout drone that skims ore off the surface.", "price": 1500, "canAfford": true,
"owned": 1, "yield": 2, "cycleSecs": 4, "reach": 1, "rate": 12007.78, "bestRate": 12007.78}, "…"],
"attachments": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png",
"description": "An overdriven motor core. Runs hot, cycles fast.", "effect": {"speed": 1.2, "yield": 1},
"price": 3000, "canAfford": true, "spare": 0, "equipped": 0, "resale": 1500}, "…"]}
"owned": 1, "yield": 4, "cycleSecs": 3, "reach": 1, "rate": 12007.78, "bestRate": 12007.78}, "…"]}
```
`s=refinery`, `state.refinery`:
+151 -241
View File
@@ -10,32 +10,29 @@ import (
"encoding/hex"
"encoding/json"
"math"
"strings"
)
// Image URLs, as the templates build them.
const (
itemImages = "/static/assets/items/"
attachmentImages = "/static/assets/attachments/"
advisorImages = "/static/assets/advisors/"
itemImages = "/static/assets/items/"
advisorImages = "/static/assets/advisors/"
)
// ------------------------------------------------------------------ always present
// APICatalog is the static game data: every tool, attachment, advisor, resource and
// refined item with its image and color.
// APICatalog is the static game data: every tool (with its upgrade tracks), advisor,
// resource and refined item with its image and color.
type APICatalog struct {
Tools []APICatalogTool `json:"tools"`
Attachments []APICatalogAttachment `json:"attachments"`
Advisors []APICatalogAdvisor `json:"advisors"`
Resources []APICatalogResource `json:"resources"`
Refined []APICatalogRefined `json:"refined"`
Reach []APIReachText `json:"reach"` // the web's wording of a reach, 1 up to the whole moon
Tools []APICatalogTool `json:"tools"`
Advisors []APICatalogAdvisor `json:"advisors"`
Resources []APICatalogResource `json:"resources"`
Refined []APICatalogRefined `json:"refined"`
Reach []APIReachText `json:"reach"` // the web's wording of a reach, 1 up to the whole moon
}
// APIReachText is how the web words a tool's reach (wells one cycle mines): label on cards
// ("Mines 3 wells", "Strips the whole moon"; "" for one), short on deploy cards ("3 wells",
// "whole moon"; "" for one), wells in the tier ladder ("1 well", "whole moon"). A reach at or
// "whole moon"; "" for one), wells ("1 well", "whole moon"). A reach at or
// above the last entry's is the whole moon.
type APIReachText struct {
Reach int `json:"reach"`
@@ -45,27 +42,31 @@ type APIReachText struct {
}
type APICatalogTool struct {
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // e.g. "PD-1"
Image string `json:"image"`
Description string `json:"description"`
Price float64 `json:"price"`
Yield float64 `json:"yield"` // base, tier 0
CycleSecs int `json:"cycleSecs"` // base
Reach int `json:"reach"` // tier 0
Tiers []APITierUp `json:"tiers"` // upgrades, tier 1 on
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // e.g. "PD-1"
Image string `json:"image"`
Description string `json:"description"`
Price float64 `json:"price"`
Yield float64 `json:"yield"` // base (level 0)
CycleSecs float64 `json:"cycleSecs"` // base
Reach int `json:"reach"`
Tracks []APICatalogTrack `json:"tracks"` // yield, then cycle
}
type APITierUp struct {
Tier int `json:"tier"`
Price float64 `json:"price"`
ClaimBoost float64 `json:"claimBoost"`
Speed float64 `json:"speed"` // cycle speed multiplier at this tier (1: none)
CycleSecs int `json:"cycleSecs"` // the tier's cycle before bonuses
Reach int `json:"reach"` // wells per cycle from this tier on
Parts []APIAmount `json:"parts"` // refined items it uses up
Image string `json:"image"` // the tool's image at this tier
// APICatalogTrack is one of a tool's upgrade tracks: its levels from 1, each setting the
// track's value (yield per cycle, or cycle seconds) before bonuses.
type APICatalogTrack struct {
Kind string `json:"kind"` // yield, cycle (upgrade with track=<kind>)
Name string `json:"name"`
Levels []APITrackLevel `json:"levels"`
}
type APITrackLevel struct {
Level int `json:"level"`
Value float64 `json:"value"`
Price float64 `json:"price"`
Parts []APIAmount `json:"parts"` // refined items it uses up
}
type APIEffect struct {
@@ -75,16 +76,6 @@ type APIEffect struct {
Refine float64 `json:"refine,omitempty"`
}
type APICatalogAttachment struct {
Name string `json:"name"`
Image string `json:"image"`
Description string `json:"description"`
Price float64 `json:"price"`
Resale float64 `json:"resale"`
Effect APIEffect `json:"effect"`
EffectText string `json:"effectText"` // e.g. "+20% speed"
}
type APICatalogAdvisor struct {
Name string `json:"name"`
Title string `json:"title"`
@@ -141,27 +132,23 @@ func apiNeed(need map[string]float64) []APIAmount {
func apiCatalog(s *State) *APICatalog {
c := cfg()
cat := &APICatalog{Tools: []APICatalogTool{}, Attachments: []APICatalogAttachment{}, Advisors: []APICatalogAdvisor{},
cat := &APICatalog{Tools: []APICatalogTool{}, Advisors: []APICatalogAdvisor{},
Resources: []APICatalogResource{}, Refined: []APICatalogRefined{}, Reach: []APIReachText{}}
for i := range s.Config.StoreItems {
item := &s.Config.StoreItems[i]
t := APICatalogTool{StoreItemID: item.ID, Name: item.Name, Code: toolCode(item.Name), Image: itemImages + item.Image,
Description: item.Description, Price: item.Price, Yield: item.ClaimAmount, CycleSecs: item.CompletionTimeInSecs,
Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}}
for _, u := range item.Upgrades {
t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost,
Speed: max(1, u.Speed), CycleSecs: item.ToolSecs(u.Tier),
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier))})
t.Tiers[len(t.Tiers)-1].Image = itemImages + item.ImageAt(u.Tier)
Description: item.Description, Price: item.Price, Yield: item.ClaimAmount,
CycleSecs: float64(item.CompletionTimeInSecs), Reach: item.ToolReach(), Tracks: []APICatalogTrack{}}
for _, kind := range trackKinds {
tr := item.Track(kind)
ct := APICatalogTrack{Kind: kind, Name: tr.Name, Levels: []APITrackLevel{}}
for n, l := range tr.Levels {
ct.Levels = append(ct.Levels, APITrackLevel{Level: n + 1, Value: l.Value, Price: l.Price, Parts: apiNeed(l.Parts)})
}
t.Tracks = append(t.Tracks, ct)
}
cat.Tools = append(cat.Tools, t)
}
for i := range c.Attachments.Items {
a := &c.Attachments.Items[i]
cat.Attachments = append(cat.Attachments, APICatalogAttachment{Name: a.Name, Image: attachmentImages + a.Image,
Description: a.Description, Price: a.Price, Resale: a.Resale(), Effect: effectOf(a.Speed, a.Yield, 0, 0),
EffectText: a.Effect()})
}
for i := range c.Advisors.Items {
a := &c.Advisors.Items[i]
cat.Advisors = append(cat.Advisors, APICatalogAdvisor{Name: a.Name, Title: a.Title, Image: advisorImages + a.Image,
@@ -210,7 +197,6 @@ type APIConfirm struct {
type APIRules struct {
WellSupplyMax float64 `json:"wellSupplyMax"` // the default grade's biggest well (each moon has its maxSupply)
MaxSupply float64 `json:"maxSupply"` // the scale of the web's supply bars: the selected moon's grade
SlotsPerTool int `json:"slotsPerTool"`
WellsMin int `json:"wellsMin"`
WellsMax int `json:"wellsMax"`
Pressure APIPressure `json:"pressure"`
@@ -218,7 +204,7 @@ type APIRules struct {
MoonResale MoonResale `json:"moonResale"`
EquipmentResale float64 `json:"equipmentResale"` // factor of the credit price
BonusCaps BonusCaps `json:"bonusCaps"`
Bonus APIEffect `json:"bonus"` // the advisors' bonus now, capped (tools add attachments)
Bonus APIEffect `json:"bonus"` // the advisors' bonus now, capped
Boosts map[string]APIMap `json:"boosts"` // live event multipliers: price, cycle, yield, by resource or "all"
}
@@ -241,7 +227,7 @@ func apiRules(p *Page) *APIRules {
s, c := p.S, cfg()
b := s.Bonus()
lo, hi := s.wellCountRange()
r := &APIRules{WellSupplyMax: s.MoonGrade().WellSupplyMax, MaxSupply: p.MaxSupply, SlotsPerTool: c.Attachments.SlotsPerTool,
r := &APIRules{WellSupplyMax: s.MoonGrade().WellSupplyMax, MaxSupply: p.MaxSupply,
WellsMin: lo, WellsMax: hi,
Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale,
HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale},
@@ -317,47 +303,48 @@ type APILiveEvent struct {
// ------------------------------------------------------------------ equipment
type APIEquipment struct {
Tools []APIToolRow `json:"tools"` // copies of a tool (same type, tier, attachments) grouped
Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
}
type APIToolRow struct {
ID string `json:"id"` // the copy shown (selected, else an idle one, else the first to finish)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"`
Image string `json:"image"`
Tier int `json:"tier"`
MaxTier int `json:"maxTier"`
Count int `json:"count"`
Idle int `json:"idle"`
Reach int `json:"reach"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Attachments []string `json:"attachments"`
Slots int `json:"slots"`
FreeSlots int `json:"freeSlots"`
Selected bool `json:"selected"`
Stake *APIStake `json:"stake"` // the shown copy's drill
Next *APINextTier `json:"next"` // null at the max tier
Preview *APIPreview `json:"preview"`
Ladder []APITierRow `json:"ladder"`
Resale float64 `json:"resale"`
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
NoSaleText string `json:"noSaleText"`
Bonus APIEffect `json:"bonus"` // advisors and its attachments, capped
Gear []APIGearDetail `json:"gear"`
ID string `json:"id"` // the copy shown (selected, else an idle one, else the first to finish)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"`
Image string `json:"image"`
YieldLevel int `json:"yieldLevel"`
CycleLevel int `json:"cycleLevel"`
Count int `json:"count"`
Idle int `json:"idle"`
Reach int `json:"reach"`
CycleSecs float64 `json:"cycleSecs"`
Yield float64 `json:"yield"`
Selected bool `json:"selected"`
Stake *APIStake `json:"stake"` // the shown copy's drill
Tracks []APITrack `json:"tracks"` // yield, then cycle
Resale float64 `json:"resale"`
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
NoSaleText string `json:"noSaleText"`
Bonus APIEffect `json:"bonus"` // advisors, capped
}
type APIGearDetail struct {
Name string `json:"name"`
Image string `json:"image"`
Effect APIEffect `json:"effect"`
EffectText string `json:"effectText"`
// APITrack is one of a tool's upgrade tracks: values are a stake's now (yield per cycle,
// or the cycle in seconds), with bonuses and live events.
type APITrack struct {
Kind string `json:"kind"` // yield, cycle: upgrade with tool=<id>&track=<kind>
Name string `json:"name"`
Level int `json:"level"`
Max int `json:"max"`
Value float64 `json:"value"`
Next *APINextLevel `json:"next"` // null at the top level
Ladder []APILadderStep `json:"ladder"`
}
type APINextTier struct {
Tier int `json:"tier"`
type APINextLevel struct {
Level int `json:"level"`
Value float64 `json:"value"`
Label string `json:"label"` // the button's track name and level, "Twin rotors 2"
Price float64 `json:"price"`
Parts []APIPart `json:"parts"`
CanAfford bool `json:"canAfford"` // credits and parts
@@ -365,8 +352,12 @@ type APINextTier struct {
Short float64 `json:"short"` // credits missing
// The web's texts: why the upgrade button is disabled (its tooltip) and the line under it
// when credits are short ("Need ₥X more").
BlockedText string `json:"blockedText"`
ShortText string `json:"shortText"`
BlockedText string `json:"blockedText"`
ShortText string `json:"shortText"`
RateFrom float64 `json:"rateFrom"` // credits per hour now and after it
RateTo float64 `json:"rateTo"`
Cost float64 `json:"cost"` // price plus the parts at sell value
PaybackSecs *float64 `json:"paybackSecs"` // null if it never pays back
}
type APIPart struct {
@@ -375,148 +366,87 @@ type APIPart struct {
Have float64 `json:"have"`
}
type APIPreview struct {
Tier int `json:"tier"`
YieldFrom float64 `json:"yieldFrom"`
YieldTo float64 `json:"yieldTo"`
SecsFrom int `json:"secsFrom"`
SecsTo int `json:"secsTo"`
ReachFrom int `json:"reachFrom"`
ReachTo int `json:"reachTo"`
RateFrom float64 `json:"rateFrom"` // credits per hour
RateTo float64 `json:"rateTo"`
Cost float64 `json:"cost"` // price plus the parts at sell value
PaybackSecs *float64 `json:"paybackSecs"` // null if it never pays back
}
type APITierRow struct {
Tier int `json:"tier"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
Rate float64 `json:"rate"` // credits per hour
Current bool `json:"current"`
Next bool `json:"next"`
type APILadderStep struct {
Level int `json:"level"`
Value float64 `json:"value"`
Rate float64 `json:"rate"` // credits per hour
Price float64 `json:"price"` // 0 for level 0
Parts []APIPart `json:"parts"`
Current bool `json:"current"`
Next bool `json:"next"`
}
type APIDrillPanel struct {
ToolID string `json:"toolId"`
Slots []APISlot `json:"slots"`
Spares []APISpare `json:"spares"`
Mining bool `json:"mining"`
Resale float64 `json:"resale"`
NoSale string `json:"noSale"`
NoSaleText string `json:"noSaleText"`
Confirm APIConfirm `json:"confirm"` // before sell-tool
IdleSame int `json:"idleSame"` // while it mines: idle tools of its type
IdleBest string `json:"idleBest"` // the one pickTool would deploy
DeployTo *APISuggestion `json:"deployTo"` // its best well
Ladder []APITierRow `json:"ladder"`
Gear []APIGearDetail `json:"gear"`
ToolID string `json:"toolId"`
Mining bool `json:"mining"`
Resale float64 `json:"resale"`
NoSale string `json:"noSale"`
NoSaleText string `json:"noSaleText"`
Confirm APIConfirm `json:"confirm"` // before sell-tool
IdleSame int `json:"idleSame"` // while it mines: idle tools of its type
IdleBest string `json:"idleBest"` // the one pickTool would deploy
DeployTo *APISuggestion `json:"deployTo"` // its best well
}
type APISlot struct {
N int `json:"n"`
Attachment string `json:"attachment"` // "" empty; unequip with item=<name>&to=<toolId>
func apiParts(rows []PartRow) []APIPart {
out := []APIPart{}
for _, p := range rows {
out = append(out, APIPart{Name: p.Name, Need: p.Need, Have: p.Have})
}
return out
}
type APISpare struct {
Name string `json:"name"`
Count int `json:"count"`
CycleSecs int `json:"cycleSecs"` // the drill's stats with it equipped
Yield float64 `json:"yield"`
CanEquip bool `json:"canEquip"`
Why string `json:"why"` // fitted, full
WhyText string `json:"whyText"`
Price float64 `json:"price"`
Resale float64 `json:"resale"`
Confirm APIConfirm `json:"confirm"` // before sell-attachment
}
func apiTierRows(rows []TierRow) []APITierRow {
out := []APITierRow{}
func apiTracks(s *State, rows []TrackRow) []APITrack {
out := []APITrack{}
for _, r := range rows {
out = append(out, APITierRow{Tier: r.Tier, CycleSecs: r.Secs, Yield: r.Yield, Reach: r.Reach, Rate: r.Rate,
Current: r.Current, Next: r.Next})
}
return out
}
func apiGearDetail(gear []*Attachment) []APIGearDetail {
out := []APIGearDetail{}
for _, a := range gear {
out = append(out, APIGearDetail{Name: a.Name, Image: attachmentImages + a.Image, Effect: effectOf(a.Speed, a.Yield, 0, 0),
EffectText: a.Effect()})
}
return out
}
func apiEquipment(p *Page) *APIEquipment {
s := p.S
e := &APIEquipment{Tools: []APIToolRow{}}
for _, row := range p.Equipment {
b := s.toolBonus(row.ID)
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
Image: itemImages + row.Item.ImageAt(row.Tier), Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle,
Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield, Attachments: apiGear(row.Gear),
Slots: len(row.Gear) + row.FreeSlots, FreeSlots: row.FreeSlots, Selected: row.Selected,
Ladder: apiTierRows(row.Ladder), Resale: row.Item.ToolResale(row.Tier), NoSale: s.noSale(row),
NoSaleText: noSaleText[s.noSale(row)],
Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}, Gear: apiGearDetail(row.Gear)}
if row.Stake != nil {
st := apiStake(*row.Stake, false)
t.Stake = &st
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value, Ladder: []APILadderStep{}}
for _, st := range r.Ladder {
t.Ladder = append(t.Ladder, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
}
if n := row.Next; n != nil {
next := &APINextTier{Tier: n.Tier, Price: n.Price, Parts: []APIPart{}, CanAfford: row.CanAfford,
Short: max(0, n.Price-s.Balance), BlockedText: row.Missing, ShortText: row.Short}
for _, part := range row.Parts {
next.Parts = append(next.Parts, APIPart{Name: part.Name, Need: part.Need, Have: part.Have})
if !part.OK() && next.Blocked == "" && next.Short == 0 {
if n := r.Next; n != nil {
next := &APINextLevel{Level: r.Level + 1, Value: r.NextValue, Label: r.Label(), Price: n.Price,
Parts: apiParts(r.Parts), CanAfford: r.CanAfford, Short: max(0, n.Price-s.Balance),
BlockedText: r.Missing, ShortText: r.Short, RateFrom: r.RateFrom, RateTo: r.RateTo, Cost: r.Cost}
for _, part := range r.Parts {
if !part.OK() && next.Blocked == "" {
next.Blocked = "parts"
}
}
if next.Short > 0 {
next.Blocked = "credits"
}
if gain := r.RateGain(); gain > 0 {
secs := r.Cost / gain * 3600
next.PaybackSecs = &secs
}
t.Next = next
}
if u := row.Preview; u != nil {
pv := &APIPreview{Tier: u.Tier, YieldFrom: u.YieldFrom, YieldTo: u.YieldTo, SecsFrom: u.SecsFrom, SecsTo: u.SecsTo,
ReachFrom: max(1, u.ReachFrom), ReachTo: max(1, u.ReachTo), RateFrom: u.RateFrom, RateTo: u.RateTo, Cost: u.Cost}
if gain := u.RateGain(); gain > 0 {
secs := u.Cost / gain * 3600
pv.PaybackSecs = &secs
}
t.Preview = pv
out = append(out, t)
}
return out
}
func apiEquipment(p *Page) *APIEquipment {
s := p.S
b := s.Bonus()
e := &APIEquipment{Tools: []APIToolRow{}}
for _, row := range p.Equipment {
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
Image: itemImages + row.Item.Image, YieldLevel: row.YieldLevel, CycleLevel: row.CycleLevel,
Count: row.Count, Idle: row.Idle, Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield,
Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), Resale: row.Item.ToolResale(row.Levels()),
NoSale: s.noSale(row), NoSaleText: noSaleText[s.noSale(row)], Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}}
if row.Stake != nil {
st := apiStake(*row.Stake, false)
t.Stake = &st
}
e.Tools = append(e.Tools, t)
}
if d := p.Drill; d != nil {
dp := &APIDrillPanel{ToolID: d.ID, Slots: []APISlot{}, Spares: []APISpare{}, Mining: d.Mining, Resale: d.Resale,
NoSale: s.noSale(d.ToolRow), NoSaleText: d.NoSale, IdleSame: d.IdleSame, Ladder: apiTierRows(d.Ladder),
Gear: apiGearDetail(d.Gear),
dp := &APIDrillPanel{ToolID: d.ID, Mining: d.Mining, Resale: d.Resale,
NoSale: s.noSale(d.ToolRow), NoSaleText: d.NoSale, IdleSame: d.IdleSame,
Confirm: APIConfirm{Title: "Sell drill", Text: d.SellConfirm(), OK: "Sell for " + formatCredits(d.Resale), Danger: true}}
for _, slot := range d.Slots {
a := APISlot{N: slot.N}
if slot.Gear != nil {
a.Attachment = slot.Gear.Name
}
dp.Slots = append(dp.Slots, a)
}
for _, sg := range d.Spares {
sp := APISpare{Name: sg.Name, Count: sg.Count, CycleSecs: sg.Secs, Yield: sg.Yield, CanEquip: sg.CanEquip,
WhyText: sg.Why, Price: sg.Price, Resale: sg.Resale(),
Confirm: APIConfirm{Title: "Sell " + sg.Name, Text: sg.Confirm(), OK: "Sell for " + formatCredits(sg.Resale()), Danger: true}}
switch {
case sg.CanEquip:
case strings.HasPrefix(sg.Why, "Already"):
sp.Why = "fitted"
default:
sp.Why = "full"
}
dp.Spares = append(dp.Spares, sp)
}
if d.IdleBest != nil {
dp.IdleBest = d.IdleBest.ID
}
@@ -535,7 +465,7 @@ func apiEquipment(p *Page) *APIEquipment {
type APIBuyModal struct {
ToolID string `json:"toolId"`
StoreItemID int `json:"storeItemId"`
CycleSecs int `json:"cycleSecs"`
CycleSecs float64 `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
NoMoons bool `json:"noMoons"`
@@ -579,8 +509,7 @@ func apiBuyModal(m *BuyModal) *APIBuyModal {
// ------------------------------------------------------------------ store
type APIStore struct {
Tools []APIStoreTool `json:"tools"`
Attachments []APIStoreGear `json:"attachments"`
Tools []APIStoreTool `json:"tools"`
}
type APIStoreTool struct {
@@ -593,27 +522,13 @@ type APIStoreTool struct {
CanAfford bool `json:"canAfford"`
Owned int `json:"owned"`
Yield float64 `json:"yield"` // base, as the card shows
CycleSecs int `json:"cycleSecs"` // base
CycleSecs float64 `json:"cycleSecs"` // base
Reach int `json:"reach"`
Rate float64 `json:"rate"` // credits per hour it would earn, with advisors
BestRate float64 `json:"bestRate"` // the same for the player's best tool
WhyText string `json:"whyText"` // why Buy is disabled ("Need ₥X more"), "" if it isn't
}
type APIStoreGear struct {
Name string `json:"name"` // buy-attachment item=<name>
Image string `json:"image"`
Description string `json:"description"`
Effect APIEffect `json:"effect"`
EffectText string `json:"effectText"`
Price float64 `json:"price"`
CanAfford bool `json:"canAfford"`
Spare int `json:"spare"`
Equipped int `json:"equipped"`
Resale float64 `json:"resale"`
WhyText string `json:"whyText"` // why Buy is disabled, "" if it isn't
}
// whyShort is the web's tooltip on a Buy button the player can't afford, "" when they can.
func whyShort(canAfford bool, balance, price float64) string {
if canAfford {
@@ -624,17 +539,12 @@ func whyShort(canAfford bool, balance, price float64) string {
func apiStore(p *Page) *APIStore {
bal := p.S.Balance
st := &APIStore{Tools: []APIStoreTool{}, Attachments: []APIStoreGear{}}
st := &APIStore{Tools: []APIStoreTool{}}
for _, r := range p.Store {
st.Tools = append(st.Tools, APIStoreTool{StoreItemID: r.Item.ID, Name: r.Item.Name, Code: toolCode(r.Item.Name),
Image: itemImages + r.Item.Image, Description: r.Item.Description, Price: r.Item.Price, CanAfford: r.CanAfford,
Owned: r.Owned, Yield: r.Item.ClaimAmount, CycleSecs: r.Item.CompletionTimeInSecs,
Reach: max(1, r.Item.ToolReach(0)), Rate: r.Rate, BestRate: r.BestRate, WhyText: whyShort(r.CanAfford, bal, r.Item.Price)})
}
for _, g := range p.Gear {
st.Attachments = append(st.Attachments, APIStoreGear{Name: g.Name, Image: attachmentImages + g.Image,
Description: g.Description, Effect: effectOf(g.Speed, g.Yield, 0, 0), EffectText: g.Attachment.Effect(), Price: g.Price,
CanAfford: g.CanAfford, Spare: g.Spare, Equipped: g.Equipped, Resale: g.Resale(), WhyText: whyShort(g.CanAfford, bal, g.Price)})
Owned: r.Owned, Yield: r.Item.ClaimAmount, CycleSecs: float64(r.Item.CompletionTimeInSecs),
Reach: r.Item.ToolReach(), Rate: r.Rate, BestRate: r.BestRate, WhyText: whyShort(r.CanAfford, bal, r.Item.Price)})
}
return st
}
@@ -911,7 +821,7 @@ func apiHelp(s *State) *APIHelp {
"Pick a well on your moon and deploy a tool on it.",
`When the drill finishes, claim what it dug up. "Claim and redeploy" puts the tool straight back to work; after a claim, "Redrill" does the same.`,
"Sell resources in the Market, upgrade your equipment, claim more moons.",
"Buy attachments in the Store and equip them on a drill (click it in Equipment) to make it faster or bigger. Hire advisors to boost your whole operation.",
"Each drill has two upgrade tracks in Equipment: one raises what a cycle mines, the other makes cycles shorter. Hire advisors to boost your whole operation.",
}, Note: "Your progress is saved on this device.",
NewGame: APIConfirm{Title: "Start over", Text: newGameConfirm, OK: "Start over", Danger: true}}
}
+12 -13
View File
@@ -102,7 +102,7 @@ func TestAPI(t *testing.T) {
t.Errorf("view %+v: %d stakes, %d tools", v, len(a.Stakes), len(a.Tools))
}
for _, st := range a.Stakes {
if st.End-st.Start != int64(st.Secs)*1000 {
if st.End-st.Start != int64(math.Round(st.Secs*1000)) {
t.Errorf("stake %s: end - start is not its duration", st.ID)
}
}
@@ -191,27 +191,28 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
if len(c.Reach) != len(c.Resources) {
fail("reach texts", c.Reach)
}
for _, at := range c.Attachments {
if at.EffectText == "" {
fail("attachment effect", at)
for _, tool := range c.Tools {
if len(tool.Tracks) != 2 || tool.Tracks[0].Kind != "yield" || tool.Tracks[1].Kind != "cycle" {
fail("tool tracks", tool)
}
}
}
if e := a.Equipment; e != nil {
for _, r := range e.Tools {
if (r.NoSale != "") != (r.NoSaleText != "") || r.Next != nil && (r.Next.Blocked != "") != (r.Next.BlockedText != "") {
if (r.NoSale != "") != (r.NoSaleText != "") {
fail("tool row texts", r)
}
for _, tr := range r.Tracks {
if n := tr.Next; n != nil && ((n.Blocked != "") != (n.BlockedText != "") || n.Label == "") ||
len(tr.Ladder) != tr.Max+1 {
fail("track texts", tr)
}
}
}
if d := e.Drill; d != nil {
if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" {
fail("drill panel texts", d)
}
for _, sp := range d.Spares {
if (sp.Why != "") != (sp.WhyText != "") || sp.Confirm.Text == "" {
fail("spare texts", sp)
}
}
}
}
if st := a.Store; st != nil {
@@ -268,11 +269,9 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
t.Errorf("view %+v: equipment %+v", v, a.Equipment)
} else if (a.Equipment.Drill != nil) != (v.Drill != "") {
t.Errorf("view %+v: drill panel %v", v, a.Equipment.Drill != nil)
} else if d := a.Equipment.Drill; d != nil && len(d.Slots) != cfg().Attachments.SlotsPerTool {
t.Errorf("view %+v: %d slots", v, len(d.Slots))
}
case "store":
if a.Store == nil || len(a.Store.Tools) != len(s.Config.StoreItems) || len(a.Store.Attachments) != len(cfg().Attachments.Items) {
if a.Store == nil || len(a.Store.Tools) != len(s.Config.StoreItems) {
t.Errorf("view %+v: store %+v", v, a.Store)
}
case "refinery":
-18
View File
@@ -1,18 +0,0 @@
# Drill attachment art
The attachment images (`web/static/assets/attachments/<image>`, `image` in
`config/game.json` `attachments.items`) are rendered in Blender (4.5 LTS, Cycles on
CPU) with the equipment pipeline: `art/equipment/lib.py` for materials, the regolith
ground, world, lights, camera and render settings.
```sh
art/attachments/render_all.sh # all four
art/attachments/render_all.sh diamond-bit # just one
```
`render.py` builds and renders at 1024px, `finish.py` (system `python3` with Pillow)
writes the 512px PNGs and contact sheets in `build/`. `--res N` and `--samples N`
give quick previews.
`parts.py`: one build function per attachment and `PARTS` (key = image name, detail
scale `S`, ground seed).
-48
View File
@@ -1,48 +0,0 @@
"""Turn raw 1024px renders into the 512px game images, plus contact sheets.
python3 art/attachments/finish.py [RAW_DIR] [--sheet DIR]
Needs Pillow. Writes web/static/assets/attachments/<name>.png for every <name>.png in RAW_DIR
(the names are the `image` slugs in config/game.json).
"""
import os
import sys
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.normpath(os.path.join(HERE, '..', '..', 'web', 'static', 'assets', 'attachments'))
def main():
a = sys.argv[1:]
sheet_dir = None
if '--sheet' in a:
i = a.index('--sheet')
sheet_dir = a[i + 1]
del a[i:i + 2]
raw = a[0] if a else os.path.join(HERE, 'build')
os.makedirs(OUT, exist_ok=True)
imgs = []
for fname in sorted(f for f in os.listdir(raw) if f.endswith('.png') and not f.startswith('contact_sheet')):
im = Image.open(os.path.join(raw, fname)).convert('RGB').resize((512, 512), Image.LANCZOS)
out = os.path.join(OUT, fname)
im.save(out, 'PNG', optimize=True)
if os.path.getsize(out) > 200_000:
q = im.quantize(256, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.FLOYDSTEINBERG)
q.save(out, 'PNG', optimize=True)
print(f'{fname}: {os.path.getsize(out) // 1024} KB')
imgs.append(Image.open(out).convert('RGB'))
if sheet_dir and imgs:
os.makedirs(sheet_dir, exist_ok=True)
for size, fn in ((512, 'contact_sheet.png'), (200, 'contact_sheet_200px.png')):
pad = 8
sheet = Image.new('RGB', (len(imgs) * (size + pad) + pad, size + 2 * pad), (4, 10, 22))
for i, im in enumerate(imgs):
sheet.paste(im.resize((size, size), Image.LANCZOS), (pad + i * (size + pad), pad))
sheet.save(os.path.join(sheet_dir, fn))
print('contact sheets in', sheet_dir)
main()
-174
View File
@@ -1,174 +0,0 @@
"""The four drill attachments. Each build function draws into a lib.Builder (art/equipment).
Coordinates: metres, Z up, front faces -Y (the camera sits front-right, +X/-Y).
Keys are the image names (`image` in config/game.json `attachments.items`, minus .png).
"""
import math
from lib import CYAN, AMBER
PI = math.pi
# Turbo Core: an overdriven motor core. Finned housing, glowing copper windings, flange.
def turbo_core(b):
z0 = 0.05
# mounting flange and feet
b.cyl(0.17, 0.035, 'graphite', (0, 0, z0 + 0.0175))
for k in range(8):
a = k / 8 * 2 * PI + PI / 8
b.cyl(0.011, 0.012, 'chrome', (math.cos(a) * 0.145, math.sin(a) * 0.145, z0 + 0.04), seg=8)
for sx in (-1, 1):
b.box((0.06, 0.32, 0.05), 'dark', (sx * 0.14, 0, 0.025), bevel=0.008)
# housing: lower stator drum with radial fins
zc = z0 + 0.035
b.cyl(0.12, 0.22, 'steel', (0, 0, zc + 0.11))
for k in range(20):
a = k / 20 * 2 * PI
with b.at((0, 0, zc + 0.11), (0, 0, a)):
b.box((0.05, 0.008, 0.2), 'graphite', (0.135, 0, 0), bevel=0.002)
for z in (zc + 0.015, zc + 0.205):
b.torus(0.155, 0.012, 'graphite', (0, 0, z))
# exposed windings: glowing core behind a cage
zw = zc + 0.22
b.cyl(0.085, 0.13, 'core_hot', (0, 0, zw + 0.065))
b.helix((0, 0), 0.092, zw + 0.008, zw + 0.122, 7, 0.008, 'brass')
for k in range(8):
a = k / 8 * 2 * PI
b.strut((math.cos(a) * 0.105, math.sin(a) * 0.105, zw), (math.cos(a) * 0.105, math.sin(a) * 0.105, zw + 0.13),
0.006, 'chrome', seg=8)
b.light((0, -0.16, zw + 0.07), AMBER, 6, 0.03)
b.light((0.16, 0.0, zw + 0.07), AMBER, 3, 0.03)
# cap with turbine fan and shaft coupling
zt = zw + 0.13
b.cyl(0.115, 0.03, 'white', (0, 0, zt + 0.015))
b.cyl(0.08, 0.02, 'dark', (0, 0, zt + 0.04))
for k in range(12):
a = k / 12 * 2 * PI
with b.at((0, 0, zt + 0.045), (0, 0, a)):
b.box((0.065, 0.004, 0.012), 'metal', (0.04, 0, 0), (0.6, 0, 0), bevel=0.001)
b.cyl(0.03, 0.08, 'chrome', (0, 0, zt + 0.08))
b.cyl(0.04, 0.02, 'graphite', (0, 0, zt + 0.125))
for k in range(4):
a = k / 4 * 2 * PI
b.box((0.012, 0.012, 0.02), 'chrome', (math.cos(a) * 0.04, math.sin(a) * 0.04, zt + 0.14), (0, 0, a), bevel=0.002)
# status lamp, label plate and cables
b.box((0.08, 0.006, 0.04), 'white', (0.0, -0.157, zc + 0.12), bevel=0.002)
b.box((0.05, 0.004, 0.006), 'amber', (0.0, -0.161, zc + 0.12), bevel=0)
b.lamp((0.1, -0.1, zt + 0.035), 0.012, 'red', housing=False)
b.tube([(-0.12, 0.08, zw + 0.03), (-0.2, 0.12, zw - 0.05), (-0.24, 0.05, 0.02)], 0.012, 'rubber')
b.tube([(-0.12, 0.03, zw + 0.06), (-0.22, 0.02, zw - 0.02), (-0.26, -0.06, 0.02)], 0.009, 'orange')
# Diamond Bit: a fluted drill bit with a diamond-studded cutting crown, upright on its shank.
def diamond_bit(b):
# shank in a coupling block on the ground
b.box((0.2, 0.2, 0.05), 'graphite', (0, 0, 0.025), bevel=0.01)
b.hazard_band(0.21, 0.21, 0.015, (0, 0, 0.055))
b.cyl(0.065, 0.08, 'steel', (0, 0, 0.1))
for k in range(6):
a = k / 6 * 2 * PI
b.box((0.018, 0.035, 0.07), 'metal', (math.cos(a) * 0.066, math.sin(a) * 0.066, 0.1), (0, 0, a), bevel=0.004)
# fluted body
z0, z1 = 0.14, 0.44
b.cyl(0.045, z1 - z0, 'graphite', (0, 0, (z0 + z1) / 2))
for k in range(2):
pts = []
n = 48
for i in range(n + 1):
t = i / n
a = k * PI + t * 2.6 * PI
pts.append((math.cos(a) * 0.055, math.sin(a) * 0.055, z0 + (z1 - z0) * t))
b.tube(pts, 0.024, 'metal', seg=12, steps=2)
# crown: carbide ring with diamond studs, cone tip
zc = z1 + 0.01
b.lathe([(0.07, zc - 0.03), (0.09, zc), (0.09, zc + 0.035), (0.08, zc + 0.045), (0.0, zc + 0.045)], 'graphite')
b.lathe([(0.078, zc + 0.045), (0.05, zc + 0.09), (0.015, zc + 0.13), (0.0, zc + 0.135)], 'steel')
for k in range(12):
a = k / 12 * 2 * PI
b.sphere(0.014, 'diamond', (math.cos(a) * 0.089, math.sin(a) * 0.089, zc + 0.02), seg=6)
for k in range(7):
a = k / 7 * 2 * PI + 0.3
b.sphere(0.011, 'diamond', (math.cos(a) * 0.05, math.sin(a) * 0.05, zc + 0.088), seg=6)
b.sphere(0.01, 'diamond', (0, 0, zc + 0.136), seg=6)
b.light((0.0, -0.15, zc + 0.06), CYAN, 3, 0.02)
# a couple of loose diamonds on the ground
for x, y in ((0.16, -0.1), (-0.13, -0.15), (0.07, -0.17)):
b.sphere(0.013, 'diamond', (x, y, 0.008), seg=6)
# Cryo Cooler: a coolant jacket that wraps the spindle. Frosted canister, glowing coolant
# windows, coiled lines and a gauge.
def cryo_cooler(b):
b.cyl(0.16, 0.03, 'graphite', (0, 0, 0.015))
b.cyl(0.15, 0.015, 'cyan_soft', (0, 0, 0.035))
# jacket
z0, z1 = 0.045, 0.34
b.cyl(0.11, z1 - z0, 'frost', (0, 0, (z0 + z1) / 2))
for z in (z0 + 0.02, (z0 + z1) / 2, z1 - 0.02):
b.torus(0.112, 0.012, 'steel', (0, 0, z))
for k in range(4):
a = k / 4 * 2 * PI + PI / 4
with b.at((0, 0, 0), (0, 0, a)):
b.box((0.012, 0.04, 0.1), 'coolant', (0.0, -0.104, z0 + 0.075), bevel=0.003)
b.box((0.012, 0.04, 0.1), 'coolant', (0.0, -0.104, z1 - 0.075), bevel=0.003)
b.light((0, -0.25, 0.19), CYAN, 8, 0.05)
# spindle through the middle
b.cyl(0.035, 0.12, 'chrome', (0, 0, z1 + 0.06))
b.cyl(0.075, 0.03, 'white', (0, 0, z1 + 0.015))
b.cyl(0.045, 0.02, 'graphite', (0, 0, z1 + 0.12))
# coolant coil around the jacket and feed lines
b.helix((0, 0), 0.128, z0 + 0.04, z1 - 0.04, 4, 0.007, 'chrome')
b.tube([(0.12, 0.05, z1 - 0.04), (0.2, 0.08, z1 + 0.02), (0.24, 0.12, 0.1), (0.26, 0.2, 0.02)], 0.014, 'white')
b.tube([(-0.12, 0.05, z0 + 0.05), (-0.2, 0.1, 0.05), (-0.22, 0.2, 0.02)], 0.012, 'rubber')
# pressure gauge
with b.at((-0.08, -0.1, z1 - 0.05), (0, 0, -0.6)):
b.cyl(0.03, 0.02, 'steel', (0, -0.02, 0), axis='y')
b.cyl(0.024, 0.005, 'white', (0, -0.031, 0), axis='y')
b.box((0.003, 0.002, 0.02), 'red', (0.005, -0.034, 0.004), (0, 0.6, 0), bevel=0)
# frost chips on the ground
for x, y, r in ((0.18, -0.06, 0.016), (-0.17, -0.1, 0.012), (0.08, -0.18, 0.01)):
b.sphere(r, 'ice', (x, y, r * 0.4), seg=10, scale=(1.2, 1.0, 0.6))
# Seam Scanner: ground-penetrating radar on a sled. Antenna panel facing the ground,
# rotating dish, a scan display.
def seam_scanner(b):
# sled runners
for sx in (-1, 1):
b.box((0.03, 0.42, 0.025), 'dark', (sx * 0.15, 0, 0.0125), bevel=0.008)
b.strut((sx * 0.15, -0.21, 0.012), (sx * 0.15, -0.25, 0.05), 0.013, 'dark')
# radar body with glowing underside emitter
b.box((0.36, 0.34, 0.08), 'white', (0, 0, 0.075), bevel=0.02)
b.box((0.3, 0.28, 0.02), 'cyan', (0, 0, 0.032), bevel=0.004)
b.light((0, 0, 0.01), CYAN, 5, 0.1)
b.hazard_band(0.37, 0.02, 0.02, (0, -0.17, 0.085))
# electronics box
b.box((0.22, 0.2, 0.1), 'graphite', (0.02, 0.04, 0.165), bevel=0.012)
b.vent(0.12, 0.05, (0.131, 0.06, 0.165), (0, 0, PI / 2))
# display with a scan grid
with b.at((-0.02, -0.08, 0.23), (-0.5, 0, 0)):
b.box((0.16, 0.012, 0.11), 'dark', (0, 0, 0), bevel=0.006)
b.box((0.14, 0.004, 0.09), 'glass', (0, -0.007, 0), bevel=0)
# mast with a dish
b.cyl(0.018, 0.16, 'steel', (0.08, 0.1, 0.29))
b.cyl(0.03, 0.02, 'graphite', (0.08, 0.1, 0.37))
b.lathe([(0.0, 0.0), (0.05, 0.012), (0.09, 0.04)], 'white', (0.08, 0.1, 0.39), (0.5, 0.0, -0.4))
b.strut((0.08, 0.1, 0.39), (0.08 - 0.02, 0.1 - 0.035, 0.45), 0.004, 'graphite')
b.lamp((0.06, 0.065, 0.455), 0.01, 'cyan', housing=False)
# antenna whips and lamp
b.strut((-0.13, 0.12, 0.215), (-0.15, 0.14, 0.42), 0.004, 'graphite')
b.lamp((-0.15, 0.14, 0.425), 0.009, 'amber', housing=False)
b.lamp((0.14, -0.1, 0.13), 0.01, 'amber')
# cable reel at the back
b.cyl(0.05, 0.05, 'orange', (-0.1, 0.13, 0.17), axis='x')
b.cyl(0.035, 0.055, 'rubber', (-0.1, 0.13, 0.17), axis='x')
PARTS = {
'turbo-core': {'build': turbo_core, 'S': 0.3, 'seed': 11},
'diamond-bit': {'build': diamond_bit, 'S': 0.25, 'seed': 12, 'fill': 0.8},
'cryo-cooler': {'build': cryo_cooler, 'S': 0.3, 'seed': 13},
'seam-scanner': {'build': seam_scanner, 'S': 0.3, 'seed': 14},
}
-87
View File
@@ -1,87 +0,0 @@
"""Build and render the drill attachment icons.
blender -b --factory-startup --python art/attachments/render.py -- [names...|all] [--out DIR]
[--res N] [--samples N]
Raw renders go to --out (default art/attachments/build); finish.py turns them into the
game images. Uses art/equipment/lib.py, so the parts match the store equipment.
"""
import os
import sys
import time
import bpy
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, '..', 'equipment'))
import lib # noqa: E402
import parts # noqa: E402
from lib import srgb # noqa: E402
def args():
a = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
opts = {'out': os.path.join(HERE, 'build'), 'res': 1024, 'samples': 96}
names = []
i = 0
while i < len(a):
k = a[i]
if k == '--out':
opts['out'] = a[i + 1]; i += 2
elif k in ('--res', '--samples'):
opts[k[2:]] = int(a[i + 1]); i += 2
else:
names.append(k); i += 1
if not names or names == ['all']:
names = list(parts.PARTS)
return names, opts
def materials(S):
M = lib.make_materials(S)
M['core_hot'] = lib.emissive('core_hot', srgb('#ff8a2b'), 9.0, base=srgb('#5a2a0a'))
M['diamond'] = lib.simple('diamond', srgb('#e8f8ff'), metal=0.3, rough=0.03, coat=1.0,
emission=srgb('#9fe8ff'), estrength=0.35)
M['frost'] = lib.paint('frost', srgb('#e4f1f8'), S, rough=0.55, panels=False, wear=0.2, dirt=0.25)
M['coolant'] = lib.emissive('coolant', srgb('#5fd4ff'), 5.0, base=srgb('#10303c'))
M['ice'] = lib.simple('ice', srgb('#cfeefc'), rough=0.15, coat=1.0, emission=srgb('#5fd4ff'), estrength=0.3)
return M
def build_scene(name):
spec = parts.PARTS[name]
lib.reset()
coll = bpy.context.scene.collection
S = spec['S']
b = lib.Builder(name, materials(S), S)
spec['build'](b)
objs = b.finish(coll)
return spec, objs, coll
def main():
names, o = args()
for name in names:
t0 = time.time()
spec, objs, coll = build_scene(name)
pts = lib.machine_points(objs)
lo = [min(p[i] for p in pts) for i in range(3)]
hi = [max(p[i] for p in pts) for i in range(3)]
span = max(hi[0] - lo[0], hi[1] - lo[1])
lib.build_ground(coll, spec['S'], span * spec.get('ground', 0.9), ((lo[0], lo[1]), (hi[0], hi[1])),
seed=spec.get('seed', 3))
lib.build_world()
lib.add_lights(coll)
lib.setup_camera(coll, pts, fill=spec.get('fill', 0.78), aim=spec.get('aim', (0.5, 0.5)))
lib.setup_render(o['res'], o['samples'])
os.makedirs(o['out'], exist_ok=True)
sc = bpy.context.scene
sc.render.filepath = os.path.join(o['out'], name + '.png')
bpy.ops.render.render(write_still=True)
print(f'[attachments] {name}: {time.time() - t0:.1f}s', flush=True)
main()
-9
View File
@@ -1,9 +0,0 @@
#!/bin/sh
# Render every attachments image (or the named ones) and write the game images.
# art/attachments/render_all.sh [name ...]
set -e
here=$(cd "$(dirname "$0")" && pwd)
blender=${BLENDER:-blender}
[ $# -eq 0 ] && set -- all
"$blender" -b --factory-startup --python "$here/render.py" -- "$@" --out "$here/build"
python3 "$here/finish.py" "$here/build" --sheet "$here/build"
-323
View File
@@ -1,323 +0,0 @@
package main
// Drill attachments: equipment bought in the Store that goes into the player's inventory,
// then is equipped on one drill in the Equipment section (the drill's side panel). An
// attachment's modifier makes the drill's stakes faster (speed) or bigger (yield). A drill
// has attachments.slotsPerTool slots and takes at most one of each kind. Equipping and
// unequipping work any time, also while the drill mines: like every bonus (bonus.go), it
// counts from the drill's next stake. Table attachment (inventory_item_id null: unequipped).
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type AttachmentConfig struct {
SlotsPerTool int `json:"slotsPerTool"`
Items []Attachment `json:"items"`
}
type Attachment struct {
Name string `json:"name"`
Description string `json:"description"`
Image string `json:"image"` // in web/static/assets/attachments
Price float64 `json:"price"`
Speed float64 `json:"speed,omitempty"` // modifiers (multipliers); unset is 1
Yield float64 `json:"yield,omitempty"`
}
func (c *GameConfig) attachment(name string) *Attachment {
for i := range c.Attachments.Items {
if c.Attachments.Items[i].Name == name {
return &c.Attachments.Items[i]
}
}
return nil
}
// Effect describes the modifier, e.g. "+20% speed".
func (a *Attachment) Effect() string {
var parts []string
if a.Speed > 1 {
parts = append(parts, pctText(a.Speed)+" speed")
}
if a.Yield > 1 {
parts = append(parts, pctText(a.Yield)+" yield")
}
return strings.Join(parts, ", ")
}
// buyAttachment buys an attachment into the player's inventory (unequipped).
func buyAttachment(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
a := cfg().attachment(name)
if a == nil {
return gameErr("That attachment isn't for sale")
}
return inTx(ctx, db, func(tx pgx.Tx) error {
if err := debit(ctx, tx, userID, a.Price); err != nil {
return err
}
_, err := tx.Exec(ctx, "insert into attachment(user_id, item) values ($1, $2)", userID, a.Name)
if err != nil {
return err
}
if err := unlock(ctx, tx, userID, "advisors"); err != nil {
return err
}
return logEvent(ctx, tx, userID, "buy_attachment", map[string]any{"item": a.Name, "price": a.Price})
})
}
// lockTool locks one of the player's tools.
func lockTool(ctx context.Context, tx pgx.Tx, userID, toolID string) error {
if !uuidPattern.MatchString(toolID) {
return gameErr("Tool not found")
}
var id string
err := tx.QueryRow(ctx, `
select id from inventory_item where id = $1 and user_id = $2 for update`, toolID, userID).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("Tool not found")
}
return err
}
// equipAttachment puts one of the player's spare attachments of a kind on a tool (an idle
// copy of it, if there is one: see pickTool) and returns the tool it went on.
func equipAttachment(ctx context.Context, db *pgxpool.Pool, userID, name, toolID string) (string, error) {
a := cfg().attachment(name)
if a == nil {
return "", gameErr("Unknown attachment")
}
err := inTx(ctx, db, func(tx pgx.Tx) error {
// The tool row first, then its attachments.
var err error
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil {
return err
}
rows, err := tx.Query(ctx, `
select item from attachment where inventory_item_id = $1 for update`, toolID)
if err != nil {
return err
}
fitted, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return err
}
for _, f := range fitted {
if f == a.Name {
return gameErr("This drill already has a %s", a.Name)
}
}
if len(fitted) >= cfg().Attachments.SlotsPerTool {
return gameErr("This drill's slots are full. Unequip one first")
}
var id string
err = tx.QueryRow(ctx, `
select id from attachment
where user_id = $1 and item = $2 and inventory_item_id is null
limit 1 for update skip locked`, userID, a.Name).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("You have no spare %s. Buy one in the store", a.Name)
} else if err != nil {
return err
}
_, err = tx.Exec(ctx, "update attachment set inventory_item_id = $2 where id = $1", id, toolID)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "equip", map[string]any{"item": a.Name, "tool": toolID})
})
return toolID, err
}
// unequipAttachment takes an attachment of a kind off a tool (an idle copy of it, if there
// is one), back into the inventory, and returns the tool it came off.
func unequipAttachment(ctx context.Context, db *pgxpool.Pool, userID, name, toolID string) (string, error) {
err := inTx(ctx, db, func(tx pgx.Tx) error {
var err error
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil {
return err
}
tag, err := tx.Exec(ctx, `
update attachment set inventory_item_id = null
where inventory_item_id = $1 and item = $2`, toolID, name)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return gameErr("That attachment isn't on this drill")
}
return logEvent(ctx, tx, userID, "unequip", map[string]any{"item": name, "tool": toolID})
})
return toolID, err
}
// ------------------------------------------------------------------ view
// gear is the attachments equipped on a tool, in config order.
func (s *State) gear(toolID string) []*Attachment {
var out []*Attachment
for i := range cfg().Attachments.Items {
a := &cfg().Attachments.Items[i]
for _, name := range s.Fitted[toolID] {
if name == a.Name {
out = append(out, a)
}
}
}
return out
}
// AttachmentCard is an attachment in the store.
type AttachmentCard struct {
*Attachment
Spare, Equipped int
CanAfford bool
}
func (s *State) attachmentCards() []AttachmentCard {
var cards []AttachmentCard
for i := range cfg().Attachments.Items {
a := &cfg().Attachments.Items[i]
card := AttachmentCard{Attachment: a, Spare: s.Spare[a.Name], CanAfford: s.Balance >= a.Price}
for _, names := range s.Fitted {
for _, n := range names {
if n == a.Name {
card.Equipped++
}
}
}
cards = append(cards, card)
}
return cards
}
// DrillPanel is the side panel for a drill selected in the Equipment section: its slots
// and the spare attachments that fit, with the stats they'd give.
type DrillPanel struct {
ToolRow
Slots []SlotGear
Spares []SpareGear
Mining bool
Resale float64 // what it sells for (resale.go)
NoSale string // why it can't be sold, if it can't
// While it mines: the idle tools of its type, the best one and its best well.
IdleSame int
IdleBest *Tool
DeployTo *Suggestion
IdleURL string // opens IdleBest's panel
// Store: the Store is unlocked, so the panel offers upgrades, attachments and selling
// (actions gated on "store", onboarding.go). Before that it shows stats and tiers only.
Store bool
}
// SlotGear is one attachment slot; Gear is nil when it's empty.
type SlotGear struct {
N int
Gear *Attachment
}
// SpareGear is a kind of spare attachment and the drill's stats with it equipped.
type SpareGear struct {
*Attachment
Count int
Secs int
Yield float64
CanEquip bool
Why string // why it can't be equipped
}
// noSale is why a tool can't be sold (only-tool, claim-first, mining), "" if it can.
func (s *State) noSale(row ToolRow) string {
switch {
case len(s.Tools) <= 1:
return "only-tool"
case row.Stake != nil && row.Stake.Ready:
return "claim-first"
case row.Stake != nil:
return "mining"
}
return ""
}
// Confirm is the question before selling one (the Sell button's confirm dialog).
func (sg SpareGear) Confirm() string {
return fmt.Sprintf("Sell a spare %s for %s? You paid %s.", sg.Name, formatCredits(sg.Resale()), formatCredits(sg.Price))
}
// SellConfirm is the question before selling the drill (its Sell button's confirm dialog).
func (d *DrillPanel) SellConfirm() string {
q := fmt.Sprintf("Sell this %s (T%d) for %s?", d.Item.Name, d.Tier, formatCredits(d.Resale))
if len(d.Gear) > 0 {
q += " Its attachments go back to your inventory."
}
return q
}
var noSaleText = map[string]string{
"only-tool": "You can't sell your only tool",
"claim-first": "Claim its haul first",
"mining": "Still mining. Sell it once it's idle",
}
func (s *State) drillPanel(row ToolRow) *DrillPanel {
d := &DrillPanel{ToolRow: row, Mining: row.Stake != nil, Resale: row.Item.ToolResale(row.Tier),
Store: s.Unlocked("store")}
d.NoSale = noSaleText[s.noSale(row)]
for i := range cfg().Attachments.SlotsPerTool {
slot := SlotGear{N: i + 1}
if i < len(row.Gear) {
slot.Gear = row.Gear[i]
}
d.Slots = append(d.Slots, slot)
}
for i := range cfg().Attachments.Items {
a := &cfg().Attachments.Items[i]
n := s.Spare[a.Name]
if n == 0 {
continue
}
sg := SpareGear{Attachment: a, Count: n, CanEquip: true}
names := append([]string{a.Name}, s.Fitted[row.ID]...)
sg.Secs, sg.Yield = drillStats(row.Item.ToolSecs(row.Tier), row.Item.ToolYield(row.Tier),
cfg().bonusesFor(s.Advisors, names), s.CycleBoosts.For("all"), s.YieldBoosts.For(""))
for _, g := range row.Gear {
if g.Name == a.Name {
sg.CanEquip, sg.Why = false, "Already on this drill"
}
}
if sg.CanEquip && row.FreeSlots == 0 {
sg.CanEquip, sg.Why = false, "Slots full. Unequip one first"
}
d.Spares = append(d.Spares, sg)
}
if d.Mining {
// Highest tier, then most attachments: the one pickTool would deploy.
for i := range s.Tools {
t := &s.Tools[i]
if t.StoreItemID != row.StoreItemID || s.StakeForTool(t.ID) != nil {
continue
}
d.IdleSame++
if b := d.IdleBest; b == nil || t.Tier > b.Tier ||
(t.Tier == b.Tier && len(s.Fitted[t.ID]) > len(s.Fitted[b.ID])) {
d.IdleBest = t
}
}
if d.IdleBest != nil {
d.IdleURL = View{Section: "equipment", Drill: d.IdleBest.ID}.URL()
if m := s.buySuggestions(d.IdleBest.ID); m != nil && len(m.Suggestions) > 0 {
d.DeployTo = &m.Suggestions[0]
}
}
}
return d
}
+3 -3
View File
@@ -27,9 +27,9 @@ func runSim(args []string) error {
for _, t := range a.Tools {
fmt.Printf("%-38s %8.0f %5d %6.0f %5d %7.0f %8.0f %8s %7s\n", t.Name, t.Price, t.Secs, t.Claim,
t.Reach, t.Eff, t.CreditsPerHour, t.Payback, t.Ratio)
for _, u := range t.Tiers {
fmt.Printf(" tier %d %6.0f + parts %5.0f yield %5.0f wells %d on moon %5.0f %8.0f ₥/h payback %s\n",
u.Tier, u.Price, u.PartsValue, u.Yield, u.Reach, u.Eff, u.CreditsPerHour, u.Payback)
for _, u := range t.Levels {
fmt.Printf(" %-5s %d %8.0f + parts %5.0f yield %5.0f cycle %4.1fs on moon %5.0f %9.0f ₥/h payback %s\n",
u.Track, u.Level, u.Price, u.PartsValue, u.Yield, u.Secs, u.Eff, u.CreditsPerHour, u.Payback)
}
}
fmt.Println("\nrefinery")
+24 -72
View File
@@ -1,8 +1,7 @@
package main
// Player bonuses: drill attachments (attachments.go, equipped on one tool; their bonus is
// the modifier) and advisors (advisors.go, for the whole operation). Each bonus is a
// multiplier (1.1 = +10%). Within a player's attachments and advisors they multiply, then each kind is capped by bonusCaps
// Player bonuses: advisors (advisors.go, for the whole operation). Each bonus is a
// multiplier (1.1 = +10%). A player's advisors multiply, then each kind is capped by bonusCaps
// in the config; live events (events.go) multiply on top, uncapped. Bonuses are applied
// when an action runs: a stake fixes its duration and amount when it starts.
@@ -15,7 +14,7 @@ import (
"github.com/jackc/pgx/v5"
)
// BonusCaps are the highest combined multipliers a player's attachments and advisors give.
// BonusCaps are the highest combined multipliers a player's advisors give.
type BonusCaps struct {
Speed float64 `json:"speed"` // drill speed: cycle duration is divided by it
Yield float64 `json:"yield"` // resources per stake
@@ -38,8 +37,8 @@ func mult(v float64) float64 {
return v
}
// bonusesFor combines the named advisors and attachments, capped.
func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
// bonusesFor combines the named advisors, capped.
func (c *GameConfig) bonusesFor(advisors []string) Bonus {
b := noBonus
for _, name := range advisors {
if a := c.advisor(name); a != nil {
@@ -49,12 +48,6 @@ func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
b.Refine *= mult(a.Refine)
}
}
for _, name := range attachments {
if m := c.attachment(name); m != nil {
b.Speed *= mult(m.Speed)
b.Yield *= mult(m.Yield)
}
}
b.Speed = min(b.Speed, c.BonusCaps.Speed)
b.Yield = min(b.Yield, c.BonusCaps.Yield)
b.Sell = min(b.Sell, c.BonusCaps.Sell)
@@ -62,10 +55,10 @@ func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
return b
}
// drillStats is a tool's cycle (seconds) and yield per stake with the player's bonus and
// the live event multipliers. stakeInTx and the views both use it.
func drillStats(secs int, yield float64, b Bonus, eventSpeed, eventYield float64) (int, float64) {
return max(1, int(math.Round(float64(secs)/(b.Speed*eventSpeed)))), yield * b.Yield * eventYield
// drillStats is a tool's cycle (seconds, to a tenth, at least 1) and yield per stake with
// the player's bonus and the live event multipliers. stakeInTx and the views both use it.
func drillStats(secs, yield float64, b Bonus, eventSpeed, eventYield float64) (float64, float64) {
return max(1, roundSecs(secs/(b.Speed*eventSpeed))), yield * b.Yield * eventYield
}
// pctText formats a multiplier as a bonus, e.g. 1.1 → "+10%".
@@ -81,25 +74,12 @@ func (cfg *GameConfig) validateBonuses() error {
}
}
okMult := func(v float64) bool { return v == 0 || v >= 1 }
if len(cfg.Attachments.Items) > 0 && cfg.Attachments.SlotsPerTool < 1 {
return fmt.Errorf("attachments: slotsPerTool must be at least 1")
}
names := map[string]bool{}
for _, m := range cfg.Attachments.Items {
if m.Name == "" || names[m.Name] {
return fmt.Errorf("attachments: names must be set and unique (%q)", m.Name)
}
names[m.Name] = true
if m.Price < 0 || !okMult(m.Speed) || !okMult(m.Yield) {
return fmt.Errorf("attachment %q: price can't be negative, speed and yield must be at least 1", m.Name)
}
}
for i, p := range cfg.Advisors.SeatPrices {
if p < 0 {
return fmt.Errorf("advisors: seat %d price is negative", i+2)
}
}
names = map[string]bool{}
names := map[string]bool{}
for _, a := range cfg.Advisors.Items {
if a.Name == "" || names[a.Name] {
return fmt.Errorf("advisors: names must be set and unique (%q)", a.Name)
@@ -114,10 +94,9 @@ func (cfg *GameConfig) validateBonuses() error {
// ------------------------------------------------------------------ in a transaction
// loadBonus reads the player's seated advisors and, with a tool id, the tool's attachments, and
// combines them. Callers that pass a tool have locked its row; the advisor rows are
// loadBonus reads the player's seated advisors and combines them. The advisor rows are
// share-locked so a hire can't change them halfway through the action.
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
func loadBonus(ctx context.Context, tx pgx.Tx, userID string) (Bonus, error) {
rows, err := tx.Query(ctx, `
select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
if err != nil {
@@ -127,23 +106,12 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, er
if err != nil {
return noBonus, err
}
var mods []string
if toolID != "" {
rows, err = tx.Query(ctx, `
select item from attachment where inventory_item_id = $1 order by item`, toolID)
if err != nil {
return noBonus, err
}
if mods, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
return noBonus, err
}
}
return cfg().bonusesFor(advisors, mods), nil
return cfg().bonusesFor(advisors), nil
}
// ------------------------------------------------------------------ state
// loadBonusState loads the player's attachments, advisors (hired, and the seated ones) and
// loadBonusState loads the player's advisors (hired, and the seated ones) and
// advisor seats into the state.
func loadBonusState(ctx context.Context, db querier, s *State) error {
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
@@ -165,36 +133,20 @@ func loadBonusState(ctx context.Context, db querier, s *State) error {
s.Advisors = append(s.Advisors, h.Name)
}
}
rows, err = db.Query(ctx, `
select coalesce(inventory_item_id::text, ''), item from attachment
where user_id = $1 order by item`, s.UserID)
if err != nil {
return err
}
s.Fitted, s.Spare = map[string][]string{}, map[string]int{}
var toolID, item string
_, err = pgx.ForEachRow(rows, []any{&toolID, &item}, func() error {
if toolID == "" {
s.Spare[item]++
} else {
s.Fitted[toolID] = append(s.Fitted[toolID], item)
}
return nil
})
return err
return nil
}
// Bonus is the player's advisor bonus (no attachments).
func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors, nil) }
// toolBonus is the bonus for stakes of one tool: advisors and its attachments.
func (s *State) toolBonus(toolID string) Bonus {
return cfg().bonusesFor(s.Advisors, s.Fitted[toolID])
}
// Bonus is the player's advisor bonus.
func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors) }
// drill is what a stake of the tool on a well of the resource would take and return now.
func (s *State) drill(t Tool, item *StoreItem, resource string) (int, float64) {
return drillStats(item.ToolSecs(t.Tier), item.ToolYield(t.Tier), s.toolBonus(t.ID),
func (s *State) drill(t Tool, item *StoreItem, resource string) (float64, float64) {
return s.drillAt(item, t.Levels(), resource)
}
// drillAt is what a stake of the item at the levels would take and return now.
func (s *State) drillAt(item *StoreItem, lv Levels, resource string) (float64, float64) {
return drillStats(item.ToolSecs(lv), item.ToolYield(lv), s.Bonus(),
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
}
+9 -38
View File
@@ -1,6 +1,6 @@
package main
// The game config (constants, store items and their upgrades, moon types) lives in
// The game config (constants, store items and their upgrade tracks, moon types) lives in
// config/game.json, embedded in the binary (`game`). Admins can override its numbers live
// (economy.go); read the effective config with cfg(). Store items are also synced into
// the database (syncStoreItems), because inventory_item references store_item and the
@@ -31,7 +31,7 @@ type GameConfig struct {
MoonGrades []MoonGrade `json:"moonGrades"`
// Selling a moon back (resale.go).
MoonResale MoonResale `json:"moonResale"`
// Selling tools and attachments back (resale.go).
// Selling tools back (resale.go).
EquipmentResale EquipmentResale `json:"equipmentResale"`
// Credits per unit sold, by resource name.
ResourcePrices map[string]float64 `json:"resourcePrices"`
@@ -45,10 +45,9 @@ type GameConfig struct {
MoonNames MoonNames `json:"moonNames"`
// Crafting refined items from resources (refinery.go).
Refinery RefineryConfig `json:"refinery"`
// Drill attachments and advisors (bonus.go): their combined bonuses are capped here.
BonusCaps BonusCaps `json:"bonusCaps"`
Attachments AttachmentConfig `json:"attachments"`
Advisors AdvisorConfig `json:"advisors"`
// Advisors (bonus.go): their combined bonuses are capped here.
BonusCaps BonusCaps `json:"bonusCaps"`
Advisors AdvisorConfig `json:"advisors"`
}
// MoonGrade is a moon vendor: its price and the moons it sells. Each well of its moons has
@@ -177,23 +176,13 @@ func (cfg *GameConfig) validate() error {
if item.Reach < 0 || item.Reach > len(cfg.ResourcePrices) {
return fmt.Errorf("store item %d: reach must be 0 to %d wells", item.ID, len(cfg.ResourcePrices))
}
for _, u := range item.Upgrades {
if u.Price < 0 {
return fmt.Errorf("store item %d tier %d: price is negative", item.ID, u.Tier)
}
if u.Reach < 0 || u.Reach > len(cfg.ResourcePrices) {
return fmt.Errorf("store item %d tier %d: reach must be 0 to %d wells", item.ID, u.Tier, len(cfg.ResourcePrices))
}
if err := cfg.validateTracks(item); err != nil {
return err
}
if ids[item.ID] {
return fmt.Errorf("duplicate store item id %d", item.ID)
}
ids[item.ID] = true
for i, u := range item.Upgrades {
if u.Tier != i+1 {
return fmt.Errorf("store item %d: upgrade tiers must be 1, 2, 3, ... in order", item.ID)
}
}
}
for _, id := range cfg.StartingTools {
if !ids[id] {
@@ -281,7 +270,8 @@ func (cfg *GameConfig) validateWells() error {
return nil
}
// syncStoreItems makes store_item and upgrade_item match the effective config. Store items
// syncStoreItems makes store_item match the effective config (the upgrade tracks are read
// from the config, not stored). Store items
// that were removed from the file are deleted only if no player owns one (deleting
// would cascade to their tools).
func syncStoreItems(ctx context.Context, db *pgxpool.Pool) error {
@@ -312,25 +302,6 @@ func syncStoreItemsTx(ctx context.Context, tx pgx.Tx, items []StoreItem) error {
if err != nil {
return fmt.Errorf("store item %d: %w", item.ID, err)
}
tiers := []int{}
for _, u := range item.Upgrades {
tiers = append(tiers, u.Tier)
_, err := tx.Exec(ctx, `
insert into upgrade_item(store_item_id, tier, price, claim_boost)
values ($1, $2, $3, $4)
on conflict (store_item_id, tier) do update set
price = excluded.price, claim_boost = excluded.claim_boost`,
item.ID, u.Tier, u.Price, u.ClaimBoost)
if err != nil {
return fmt.Errorf("store item %d tier %d: %w", item.ID, u.Tier, err)
}
}
_, err = tx.Exec(ctx, `
delete from upgrade_item where store_item_id = $1 and not (tier = any($2))`,
item.ID, tiers)
if err != nil {
return fmt.Errorf("store item %d: %w", item.ID, err)
}
}
_, err := tx.Exec(ctx, `
+124 -75
View File
@@ -32,115 +32,177 @@
"description": "A hovering scout drone that skims ore off the surface.",
"image": "shovel.png",
"price": 1200,
"claimAmount": 4,
"claimAmount": 8,
"completionTimeInSecs": 3,
"upgrades": [
{"tier": 1, "price": 80, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 100, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 80, "claimBoost": 0, "speed": 1.4, "parts": {"Dusk Alloy": 1}},
{"tier": 4, "price": 110, "claimBoost": 0, "speed": 1.6, "parts": {"Dusk Alloy": 1}},
{"tier": 5, "price": 120, "claimBoost": 0, "speed": 1.8, "parts": {"Dusk Alloy": 2}}
]
"tracks": {
"yield": {"name": "Twin rotors", "levels": [
{"value": 10, "price": 280},
{"value": 12, "price": 280},
{"value": 14, "price": 290, "parts": {"Dusk Alloy": 1}},
{"value": 16, "price": 280, "parts": {"Dusk Alloy": 2}}
]},
"cycle": {"name": "Flight computer", "levels": [
{"value": 2.6, "price": 180},
{"value": 2.2, "price": 240},
{"value": 1.9, "price": 250, "parts": {"Dusk Alloy": 1}},
{"value": 1.7, "price": 210, "parts": {"Dusk Alloy": 1}},
{"value": 1.5, "price": 270, "parts": {"Dusk Alloy": 2}}
]}
}
},
{
"id": 2,
"name": "Percussion Drill PX-4",
"description": "Hammers the crust a thousand times a second to crack open ore seams.",
"image": "pickaxe.png",
"price": 5700,
"claimAmount": 20,
"price": 4000,
"claimAmount": 60,
"completionTimeInSecs": 6,
"upgrades": [
{"tier": 1, "price": 400, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 470, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 500, "claimBoost": 0, "speed": 1.4, "parts": {"Dusk Alloy": 2}},
{"tier": 4, "price": 610, "claimBoost": 0, "speed": 1.6, "parts": {"Cryo Cell": 1}},
{"tier": 5, "price": 780, "claimBoost": 0, "speed": 1.8, "parts": {"Plasma Weave": 1}}
]
"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}}
]},
"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}}
]}
}
},
{
"id": 3,
"name": "LunarLite L100",
"description": "An entry-level rotary drill: point it down and the ore rolls in.",
"image": "lunarlite.jpg",
"price": 27000,
"claimAmount": 110,
"price": 13400,
"claimAmount": 300,
"completionTimeInSecs": 8,
"upgrades": [
{"tier": 1, "price": 1700, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 4500, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 2900, "claimBoost": 0, "speed": 1.4, "parts": {"Cryo Cell": 1}},
{"tier": 4, "price": 3400, "claimBoost": 0, "speed": 1.6, "parts": {"Plasma Weave": 2}},
{"tier": 5, "price": 4300, "claimBoost": 0, "speed": 1.8, "parts": {"Plasma Weave": 3}}
]
"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}}
]},
"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}}
]}
}
},
{
"id": 4,
"name": "LunarLite L300",
"description": "The L100's laser-cutting big brother, built to burn through bedrock.",
"image": "lunarlite300.png",
"price": 290000,
"claimAmount": 1350,
"price": 129000,
"claimAmount": 4000,
"completionTimeInSecs": 10,
"reach": 2,
"upgrades": [
{"tier": 1, "price": 37000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 23000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 28000, "claimBoost": 0, "speed": 1.4, "parts": {"Cryo Cell": 2}},
{"tier": 4, "price": 34000, "claimBoost": 0, "speed": 1.6, "parts": {"Plasma Weave": 3}},
{"tier": 5, "price": 42000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 1}}
]
"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}}
]},
"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}}
]}
}
},
{
"id": 5,
"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": 3200000,
"claimAmount": 17000,
"price": 1180000,
"claimAmount": 48000,
"completionTimeInSecs": 12,
"reach": 3,
"upgrades": [
{"tier": 1, "price": 380000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 230000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 280000, "claimBoost": 0, "speed": 1.4, "parts": {"Plasma Weave": 3}},
{"tier": 4, "price": 740000, "claimBoost": 0, "speed": 1.6, "parts": {"Void Core": 1}},
{"tier": 5, "price": 510000, "claimBoost": 0, "speed": 1.8, "parts": {"Stellar Lattice": 1}}
]
"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}}
]},
"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}}
]}
}
},
{
"id": 6,
"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": 7400000,
"claimAmount": 36000,
"price": 2120000,
"claimAmount": 110000,
"completionTimeInSecs": 14,
"reach": 5,
"upgrades": [
{"tier": 1, "price": 470000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 1200000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 820000, "claimBoost": 0, "speed": 1.4, "parts": {"Void Core": 1}},
{"tier": 4, "price": 1000000, "claimBoost": 0, "speed": 1.6, "parts": {"Stellar Lattice": 1}},
{"tier": 5, "price": 1300000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
]
"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}}
]},
"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}}
]}
}
},
{
"id": 7,
"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": 11000000,
"claimAmount": 47000,
"price": 3100000,
"claimAmount": 200000,
"completionTimeInSecs": 16,
"reach": 5,
"upgrades": [
{"tier": 1, "price": 840000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 1000000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 1300000, "claimBoost": 0, "speed": 1.4, "parts": {"Void Core": 2}},
{"tier": 4, "price": 1600000, "claimBoost": 0, "speed": 1.6, "parts": {"Stellar Lattice": 2}},
{"tier": 5, "price": 2200000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
]
"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}}
]},
"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}}
]}
}
}
],
"moons": [
@@ -225,19 +287,6 @@
]
},
"bonusCaps": {"speed": 1.6, "yield": 1.6, "sell": 1.15, "refine": 1.5},
"attachments": {
"slotsPerTool": 2,
"items": [
{"name": "Turbo Core", "image": "turbo-core.png", "price": 900, "speed": 1.2,
"description": "An overdriven motor core. Runs hot, cycles fast."},
{"name": "Diamond Bit", "image": "diamond-bit.png", "price": 950, "yield": 1.15,
"description": "A synthetic-diamond cutting face that keeps more ore in every bite."},
{"name": "Cryo Cooler", "image": "cryo-cooler.png", "price": 85000, "speed": 1.35,
"description": "Wraps the spindle in coolant so the drill never stops to cool down."},
{"name": "Seam Scanner", "image": "seam-scanner.png", "price": 90000, "yield": 1.3,
"description": "Ground radar that steers the head into the richest part of the seam."}
]
},
"advisors": {
"seatPrices": [20000, 400000, 8000000],
"items": [
+75 -199
View File
@@ -11,32 +11,16 @@ import (
"encoding/hex"
"errors"
"fmt"
"io/fs"
"maps"
"math"
mrand "math/rand/v2"
"path"
"slices"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Upgrade struct {
Tier int `json:"tier"`
Price float64 `json:"price"`
ClaimBoost float64 `json:"claimBoost"`
// Refined items (by name) used up by this upgrade, besides the price (refinery.go).
Parts map[string]float64 `json:"parts,omitempty"`
// Wells mined per cycle from this tier on (see StoreItem.Reach); 0 keeps the tool's.
Reach int `json:"reach,omitempty"`
// Cycle speed multiplier at this tier (1.25: cycles take 1/1.25 as long); 0 or 1: none.
// Like claimBoost it is the tier's total, not added to the tiers below. Read from cfg().
Speed float64 `json:"speed,omitempty"`
}
type StoreItem struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -47,9 +31,10 @@ type StoreItem struct {
CompletionTimeInSecs int `json:"completionTimeInSecs"`
// How many wells of a moon one cycle mines (0 or 1: the one it's deployed on). A tool
// with a reach as big as a moon's well count (len(resourcePrices)) strips the whole moon.
// Read from cfg() like upgrade parts, not stored in store_item.
Reach int `json:"reach,omitempty"`
Upgrades []Upgrade `json:"upgrades"`
// Read from cfg() like the tracks, not stored in store_item.
Reach int `json:"reach,omitempty"`
// The two upgrade tracks, yield and cycle (tracks.go). Read from cfg().
Tracks Tracks `json:"tracks"`
}
// Config is the part of the game config the UI needs. Store items come from the
@@ -90,7 +75,8 @@ type Moon struct {
type Tool struct {
ID string
StoreItemID int
Tier int
YieldLevel int // levels on its upgrade tracks (tracks.go)
CycleLevel int
}
// Stake is an unclaimed staking event: a tool mining a well.
@@ -102,12 +88,12 @@ type Stake struct {
Resource string
Amount float64
Start time.Time
DurationInSecs int
DurationInSecs float64
Reach int // wells of the moon it mines (StoreItem.Reach); WellID is the first
}
func (s Stake) End() time.Time {
return s.Start.Add(time.Duration(s.DurationInSecs) * time.Second)
return s.Start.Add(time.Duration(s.DurationInSecs * float64(time.Second)))
}
func (s Stake) Remaining(now time.Time) time.Duration {
@@ -144,11 +130,8 @@ type State struct {
YieldBoosts PriceBoosts // active live yield events
Pressure map[string]float64 // the player's selling pressure by item, now (market.go)
// Bonuses (bonus.go): attachments equipped on each tool and spare ones (by kind),
// seated advisors (their bonus counts), every advisor hired with their seat, advisor
// seats owned.
Fitted map[string][]string
Spare map[string]int
// Bonuses (bonus.go): seated advisors (their bonus counts), every advisor hired with
// their seat, advisor seats owned.
Advisors []string
Hires []AdvisorHire // seated first, by seat
AdvisorSeats int
@@ -190,60 +173,6 @@ func (s *State) StakeForTool(toolID string) *Stake {
return nil
}
// ToolYield is what one completed stake returns at the given tier.
func (item *StoreItem) ToolYield(tier int) float64 {
for _, u := range item.Upgrades {
if u.Tier == tier {
return item.ClaimAmount + u.ClaimBoost
}
}
return item.ClaimAmount
}
// ToolSecs is a stake's cycle at the given tier, before bonuses: the tool's cycle over the
// tier's speed.
func (item *StoreItem) ToolSecs(tier int) int {
if u := item.upgrade(tier); u != nil && u.Speed > 1 {
return max(1, int(math.Round(float64(item.CompletionTimeInSecs)/u.Speed)))
}
return item.CompletionTimeInSecs
}
// ToolReach is how many wells a stake mines at the given tier: the reach of the highest
// tier up to it that sets one, else the tool's, at least 1.
func (item *StoreItem) ToolReach(tier int) int {
reach := item.Reach
for _, u := range item.Upgrades {
if u.Tier <= tier && u.Reach > 0 {
reach = u.Reach
}
}
return max(1, reach)
}
// ImageAt is the tool's image at a tier: tier N is the image's name with "-tN" before the
// extension (drawn by scripts/tool-art.mjs), else the tier 0 image when that file is missing.
func (item *StoreItem) ImageAt(tier int) string {
if tier <= 0 {
return item.Image
}
ext := path.Ext(item.Image)
name := fmt.Sprintf("%s-t%d%s", strings.TrimSuffix(item.Image, ext), tier, ext)
if _, err := fs.Stat(embedded, "web/static/assets/items/"+name); err != nil {
return item.Image
}
return name
}
func (item *StoreItem) NextUpgrade(tier int) *Upgrade {
for i := range item.Upgrades {
if item.Upgrades[i].Tier == tier+1 {
return &item.Upgrades[i]
}
}
return nil
}
// ------------------------------------------------------------------ reads
func loadConfig(ctx context.Context, db *pgxpool.Pool) (Config, error) {
@@ -274,33 +203,11 @@ func loadConfig(ctx context.Context, db *pgxpool.Pool) (Config, error) {
return cfg, err
}
rows, err = db.Query(ctx, `
select store_item_id, tier, price, claim_boost from upgrade_item
order by store_item_id, tier`)
if err != nil {
return cfg, err
}
var itemID int
var u Upgrade
_, err = pgx.ForEachRow(rows, []any{&itemID, &u.Tier, &u.Price, &u.ClaimBoost}, func() error {
for i := range cfg.StoreItems {
if cfg.StoreItems[i].ID == itemID {
cfg.StoreItems[i].Upgrades = append(cfg.StoreItems[i].Upgrades, u)
}
}
return nil
})
// Reach and upgrade speed aren't in the tables: they come from the game config.
// Reach and the upgrade tracks aren't in the tables: they come from the game config.
for i := range cfg.StoreItems {
item := &cfg.StoreItems[i]
if c := configItem(item.ID); c != nil {
item.Reach = c.Reach
for j := range item.Upgrades {
if up := c.upgrade(item.Upgrades[j].Tier); up != nil {
item.Upgrades[j].Reach = up.Reach
item.Upgrades[j].Speed = up.Speed
}
}
item.Reach, item.Tracks = c.Reach, c.Tracks
}
}
return cfg, err
@@ -317,19 +224,10 @@ func configItem(id int) *StoreItem {
return nil
}
func (item *StoreItem) upgrade(tier int) *Upgrade {
for i := range item.Upgrades {
if item.Upgrades[i].Tier == tier {
return &item.Upgrades[i]
}
}
return nil
}
// toolReach is how many wells a stake of the store item at the tier mines (game config).
func toolReach(storeItemID, tier int) int {
// toolReach is how many wells a stake of the store item mines (game config).
func toolReach(storeItemID int) int {
if item := configItem(storeItemID); item != nil {
return item.ToolReach(tier)
return item.ToolReach()
}
return 1
}
@@ -414,7 +312,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
}
rows, err = db.Query(ctx, `
select id, store_item_id, tier from inventory_item
select id, store_item_id, yield_level, cycle_level from inventory_item
where user_id = $1 order by store_item_id, created_at, id`, userID)
if err != nil {
return nil, err
@@ -951,51 +849,6 @@ func buyTool(ctx context.Context, db *pgxpool.Pool, userID string, storeItemID i
return toolID, err
}
// upgradeTool upgrades a tool (an idle copy of it, if there is one: see pickTool) and
// returns the one it upgraded.
func upgradeTool(ctx context.Context, db *pgxpool.Pool, userID, toolID string) (string, error) {
err := inTx(ctx, db, func(tx pgx.Tx) error {
var err error
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil {
return err
}
var storeItemID, tier int
err = tx.QueryRow(ctx, "select store_item_id, tier from inventory_item where id = $1",
toolID).Scan(&storeItemID, &tier)
if err != nil {
return err
}
var price float64
err = tx.QueryRow(ctx, `
select price from upgrade_item where store_item_id = $1 and tier = $2`,
storeItemID, tier+1).Scan(&price)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("This tool is already at its highest tier")
} else if err != nil {
return err
}
if err := debit(ctx, tx, userID, price); err != nil {
return err
}
if err := takeItems(ctx, tx, userID, upgradeParts(storeItemID, tier+1)); err != nil {
return err
}
_, err = tx.Exec(ctx, "update inventory_item set tier = tier + 1 where id = $1", toolID)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "insert into upgrade_event(inventory_item_id) values ($1)", toolID)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "upgrade",
map[string]any{"item": storeItemID, "tier": tier + 1, "price": price})
})
return toolID, err
}
// stake puts an idle tool to work on one of the player's wells. If the tool is busy (say
// the page was stale), another idle tool of the same type goes instead.
func stake(ctx context.Context, db *pgxpool.Pool, userID, toolID, wellID string) error {
@@ -1008,20 +861,18 @@ func stake(ctx context.Context, db *pgxpool.Pool, userID, toolID, wellID string)
})
}
// pickTool locks the tool an action on toolID should use. Copies of a tool (same type,
// tier and attachments) are interchangeable, so when toolID is busy (an unclaimed stake)
// an idle copy is used instead; with anyTier, any idle tool of the same type, the best
// one first (a copy, then highest tier, most attachments). Without an idle one it's
// toolID itself, and the action reports why it can't use it.
func pickTool(ctx context.Context, tx pgx.Tx, userID, toolID string, anyTier bool) (string, error) {
// pickTool locks the tool an action on toolID should use. Copies of a tool (same type and
// track levels) are interchangeable, so when toolID is busy (an unclaimed stake) an idle
// copy is used instead; with anyLevel, any idle tool of the same type, the best one first
// (a copy, then the most levels). Without an idle one it's toolID itself, and the action
// reports why it can't use it.
func pickTool(ctx context.Context, tx pgx.Tx, userID, toolID string, anyLevel bool) (string, error) {
if !uuidPattern.MatchString(toolID) {
return "", gameErr("Tool not found")
}
rows, err := tx.Query(ctx, `
with tool as (
select inventory_item.id, store_item_id, tier,
array(select item from attachment where inventory_item_id = inventory_item.id
order by item) as gear,
select inventory_item.id, store_item_id, yield_level, cycle_level,
exists(select 1 from staking_event
where inventory_item_id = inventory_item.id
and not exists (select 1 from claim_event
@@ -1030,9 +881,10 @@ func pickTool(ctx context.Context, tx pgx.Tx, userID, toolID string, anyTier boo
)
select tool.id from tool join tool me on me.id = $2
where tool.store_item_id = me.store_item_id and not tool.busy
and ($3 or (tool.tier = me.tier and tool.gear = me.gear))
order by tool.id = me.id desc, (tool.tier = me.tier and tool.gear = me.gear) desc,
tool.tier desc, cardinality(tool.gear) desc, tool.id`, userID, toolID, anyTier)
and ($3 or (tool.yield_level = me.yield_level and tool.cycle_level = me.cycle_level))
order by tool.id = me.id desc,
(tool.yield_level = me.yield_level and tool.cycle_level = me.cycle_level) desc,
tool.yield_level + tool.cycle_level desc, tool.yield_level desc, tool.id`, userID, toolID, anyLevel)
if err != nil {
return "", err
}
@@ -1117,23 +969,21 @@ func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) er
if err != nil {
return err
}
var duration, storeItemID, tier int
var amount float64
var storeItemID int
var lv Levels
err = tx.QueryRow(ctx, `
select store_item.completion_time_in_secs,
store_item.claim_amount + coalesce(upgrade_item.claim_boost, 0),
store_item.id, inventory_item.tier
from inventory_item
join store_item on store_item.id = inventory_item.store_item_id
left join upgrade_item on upgrade_item.store_item_id = store_item.id
and upgrade_item.tier = inventory_item.tier
where inventory_item.id = $1 and inventory_item.user_id = $2
for update of inventory_item`, toolID, userID).Scan(&duration, &amount, &storeItemID, &tier)
select store_item_id, yield_level, cycle_level from inventory_item
where id = $1 and user_id = $2
for update`, toolID, userID).Scan(&storeItemID, &lv.Yield, &lv.Cycle)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("Tool not found")
} else if err != nil {
return err
}
item := configItem(storeItemID)
if item == nil {
return gameErr("Tool not found")
}
// A tool is busy until its stake is claimed.
var busy bool
@@ -1165,7 +1015,7 @@ func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) er
}
// A tool that mines several wells (reach) is tapped out only when the whole moon is:
// on an empty well it goes to the moon's fullest one instead (Redrill, redeploy).
reach := toolReach(storeItemID, tier)
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
@@ -1179,9 +1029,9 @@ func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) er
return errWellEmpty
}
// Attachments, advisors and live events (a cycle multiplier of 2 halves the stake's
// Advisors and live events (a cycle multiplier of 2 halves the stake's
// duration) count for the stakes started while they apply: fixed now, not at claim time.
bonus, err := loadBonus(ctx, tx, userID, toolID)
bonus, err := loadBonus(ctx, tx, userID)
if err != nil {
return err
}
@@ -1195,10 +1045,7 @@ func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) er
if err != nil {
return err
}
if item := configItem(storeItemID); item != nil {
duration = item.ToolSecs(tier)
}
duration, amount = drillStats(duration, amount, bonus, speed, yield)
duration, amount := drillStats(item.ToolSecs(lv), item.ToolYield(lv), bonus, speed, yield)
// The drills already on the well (and multi-well ones reaching it) will take all it
// has left: this one would get nothing.
@@ -1350,8 +1197,7 @@ func redrill(ctx context.Context, db *pgxpool.Pool, userID, wellID string) (int,
var n int
err := inTx(ctx, db, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
select inventory_item.id, store_item.claim_amount from inventory_item
join store_item on store_item.id = inventory_item.store_item_id
select inventory_item.id, store_item_id, yield_level, cycle_level from inventory_item
join lateral (
select staking_event.id, well_id from staking_event
where inventory_item_id = inventory_item.id
@@ -1365,16 +1211,46 @@ func redrill(ctx context.Context, db *pgxpool.Pool, userID, wellID string) (int,
return err
}
type idle struct {
ID string
Amount float64
ID string
StoreItemID int
Lv Levels
Amount float64 // what a stake of it takes here: its yield-track value, bonuses and events
}
tools, err := pgx.CollectRows(rows, pgx.RowToStructByPos[idle])
var tools []idle
var t idle
_, err = pgx.ForEachRow(rows, []any{&t.ID, &t.StoreItemID, &t.Lv.Yield, &t.Lv.Cycle}, func() error {
tools = append(tools, t)
return nil
})
if err != nil {
return err
}
if len(tools) == 0 {
return gameErr("No idle drills to redrill here")
}
bonus, err := loadBonus(ctx, tx, userID)
if err != nil {
return err
}
var resource string
err = tx.QueryRow(ctx, `select resource.name from resource_well join resource on resource.id = resource_id
where resource_well.id = $1`, wellID).Scan(&resource)
if err != nil {
return err
}
speed, err := eventMultiplier(ctx, tx, "cycle_multiplier", "all")
if err != nil {
return err
}
yield, err := eventMultiplier(ctx, tx, "yield_multiplier", resource)
if err != nil {
return err
}
for i := range tools {
if item := configItem(tools[i].StoreItemID); item != nil {
_, tools[i].Amount = drillStats(item.ToolSecs(tools[i].Lv), item.ToolYield(tools[i].Lv), bonus, speed, yield)
}
}
var moonID string
if err := tx.QueryRow(ctx, "select source_id from resource_well where id = $1", wellID).Scan(&moonID); err != nil {
return err
@@ -1690,7 +1566,7 @@ func sell(ctx context.Context, db *pgxpool.Pool, userID, resource string, amount
if err != nil {
return err
}
bonus, err := loadBonus(ctx, tx, userID, "")
bonus, err := loadBonus(ctx, tx, userID)
if err != nil {
return err
}
+78 -80
View File
@@ -4,7 +4,7 @@ package main
// players are active: they claim (claim all with redeploy) as soon as a drill is ready (at
// most every gsClaimGap), stake drills whose well ran dry, sell every half minute and buy, all within a
// click budget (gsClicksPerSec). The "copies" player buys only drill copies (and moons); the
// "boosts" player buys whatever adds the most ₥/minute per ₥ (upgrades, attachments,
// "boosts" player buys whatever adds the most ₥/minute per ₥ (a level on a yield or cycle track,
// advisors or copies). Both buy a moon when their drills are about to run dry. Every report
// row lists the best buy of each kind with what it adds per ₥ spent, in ₥/minute and in
// ₥/click, and the summary compares the boosts with the copies.
@@ -15,7 +15,6 @@ import (
"math/rand/v2"
"slices"
"sort"
"strings"
)
type gsPlayer struct {
@@ -38,10 +37,10 @@ const (
)
type gsTool struct {
item, tier int
mods []string
doneAt int // busy until then; -1 idle
take []gsTake
item int
lv Levels
doneAt int // busy until then; -1 idle
take []gsTake
}
type gsTake struct {
@@ -61,21 +60,20 @@ type gsMoon struct {
free float64 // ore not yet taken or booked
}
// gsGroup is the drills of one kind (store item, tier, attachments); tool is one of them.
// gsGroup is the drills of one kind (store item, track levels); tool is one of them.
type gsGroup struct {
item, tier int
mods []string
n, tool int
item int
lv Levels
n, tool int
}
func gsKey(item, tier int, mods []string) string { return fmt.Sprint(item, tier, mods) }
// GSBuy is a candidate purchase: what it adds and what it costs.
type GSBuy struct {
Kind, Name string
Cost float64 // ₥
Gain, Click float64 // ₥/hour added, ₥/click added
tool int // upgrade or attachment: which tool; copy: which store item
tool int // upgrade: which tool; copy: which store item
track string // upgrade: yield or cycle
}
func (b GSBuy) Payback() string {
@@ -162,7 +160,7 @@ func gradeStats(c *GameConfig) []GradeStat {
if st.Drill != "" {
st.Drill += ", "
}
perWell := item.ClaimAmount / float64(item.ToolReach(0))
perWell := item.ClaimAmount / float64(item.ToolReach())
st.Drill += fmt.Sprintf("%s (%.1f claims, %ds)", toolCode(item.Name), g.MeanSupply()/perWell,
item.CompletionTimeInSecs)
}
@@ -180,17 +178,17 @@ func gradeStats(c *GameConfig) []GradeStat {
func gsEval(c *GameConfig, stats []GradeStat, caps func(int) ReachCaps, grade int, gs []gsGroup, adv []string) (inc, clk float64) {
st := stats[grade]
stakes := 0.0
cycles := map[int]bool{}
cycles := map[float64]bool{}
b := c.bonusesFor(adv)
for _, x := range gs {
if x.n == 0 {
continue
}
it := &c.StoreItems[x.item]
b := c.bonusesFor(adv, x.mods)
secs, y := drillStats(it.ToolSecs(x.tier), it.ToolYield(x.tier), b, 1, 1)
reach := it.ToolReach(x.tier)
secs, y := drillStats(it.ToolSecs(x.lv), it.ToolYield(x.lv), b, 1, 1)
reach := it.ToolReach()
cycles[max(secs, gsClaimGap)] = true
p := float64(max(secs, gsClaimGap)) + 0.5 // waits half a tick on average
p := max(secs, gsClaimGap) + 0.5 // waits half a tick on average
n := float64(x.n)
inc += n * caps(grade).Yield(y, reach) * st.Unit * b.Sell * 3600 / p
per := st.Units / max(1, st.Wells) / y
@@ -201,7 +199,7 @@ func gsEval(c *GameConfig, stats []GradeStat, caps func(int) ReachCaps, grade in
}
claims := 0.0
for secs := range cycles {
claims += 3600 / float64(secs)
claims += 3600 / secs
}
clk = min(claims, 3600/gsClaimGap) + stakes + 3600/gsSellEvery*min(5, math.Round(st.Wells))
if budget := 3600 * gsClicksPerSec; clk > budget {
@@ -250,30 +248,29 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
idx := map[string]int{}
var out []gsGroup
for j, t := range tools {
k := gsKey(t.item, t.tier, t.mods)
k := fmt.Sprint(t.item, t.lv)
if i, ok := idx[k]; ok {
out[i].n++
continue
}
idx[k] = len(out)
out = append(out, gsGroup{t.item, t.tier, t.mods, 1, j})
out = append(out, gsGroup{t.item, t.lv, 1, j})
}
return out
}
// with is gs with one drill of group k (none if k < 0) turned into (item, tier, mods).
with := func(gs []gsGroup, k, item, tier int, mods []string) []gsGroup {
// with is gs with one drill of group k (none if k < 0) turned into (item, lv).
with := func(gs []gsGroup, k, item int, lv Levels) []gsGroup {
out := slices.Clone(gs)
if k >= 0 {
out[k].n--
}
key := gsKey(item, tier, mods)
for i := range out {
if gsKey(out[i].item, out[i].tier, out[i].mods) == key {
if out[i].item == item && out[i].lv == lv {
out[i].n++
return out
}
}
return append(out, gsGroup{item, tier, mods, 1, -1})
return append(out, gsGroup{item, lv, 1, -1})
}
eval := func(gs []gsGroup, adv []string) (float64, float64) { return gsEval(c, stats, caps, grade, gs, adv) }
partsCost := func(parts map[string]float64) (float64, bool) {
@@ -294,25 +291,20 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
var out []GSBuy
if len(tools) < gsMaxTools {
for i, it := range c.StoreItems {
out = append(out, buy("copy", toolCode(it.Name), it.Price, i, with(gs, -1, i, 0, nil), advisors))
out = append(out, buy("copy", toolCode(it.Name), it.Price, i, with(gs, -1, i, Levels{}), advisors))
}
}
for k, x := range gs {
it := &c.StoreItems[x.item]
if u := it.NextUpgrade(x.tier); u != nil {
if pc, ok := partsCost(u.Parts); ok {
out = append(out, buy("upgrade", fmt.Sprintf("%s t%d", toolCode(it.Name), u.Tier), u.Price+pc,
x.tool, with(gs, k, x.item, u.Tier, x.mods), advisors))
}
}
if len(x.mods) < c.Attachments.SlotsPerTool {
for _, m := range c.Attachments.Items {
if slices.Contains(x.mods, m.Name) {
continue
for _, kind := range trackKinds {
n := x.lv.Of(kind) + 1
if u := it.TrackLevel(kind, n); u != nil {
if pc, ok := partsCost(u.Parts); ok {
b := buy("upgrade", fmt.Sprintf("%s %s%d", toolCode(it.Name), kind[:1], n), u.Price+pc,
x.tool, with(gs, k, x.item, x.lv.With(kind, n)), advisors)
b.track = kind
out = append(out, b)
}
mods := append(slices.Clone(x.mods), m.Name)
out = append(out, buy("attachment", m.Name+" on "+toolCode(it.Name), m.Price, x.tool,
with(gs, k, x.item, x.tier, mods), advisors))
}
}
}
@@ -347,7 +339,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
sum := 0.0
for _, t := range tools {
it := &c.StoreItems[t.item]
_, y := drillStats(it.ToolSecs(t.tier), it.ToolYield(t.tier), c.bonusesFor(advisors, t.mods), 1, 1)
_, y := drillStats(it.ToolSecs(t.lv), it.ToolYield(t.lv), c.bonusesFor(advisors), 1, 1)
sum += 2 * y
}
return sum
@@ -394,7 +386,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
// book stakes a tool on moon mi: on well only (a one-well redeploy), else its fullest wells.
book := func(t int, tl *gsTool, mi, only int) bool {
it := &c.StoreItems[tl.item]
secs, y := drillStats(it.ToolSecs(tl.tier), it.ToolYield(tl.tier), c.bonusesFor(advisors, tl.mods), 1, 1)
secs, y := drillStats(it.ToolSecs(tl.lv), it.ToolYield(tl.lv), c.bonusesFor(advisors), 1, 1)
m := moons[mi]
var order []int
if only >= 0 {
@@ -406,9 +398,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
slices.SortFunc(order, func(a, b int) int {
return cmpDesc(m.wells[a].left-m.wells[a].booked, m.wells[b].left-m.wells[b].booked)
})
order = order[:min(it.ToolReach(tl.tier), len(order))]
order = order[:min(it.ToolReach(), len(order))]
}
share := y / float64(it.ToolReach(tl.tier))
share := y / float64(it.ToolReach())
tl.take = tl.take[:0]
for _, wi := range order {
w := &m.wells[wi]
@@ -423,7 +415,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
if len(tl.take) == 0 {
return false
}
tl.doneAt = t + secs
tl.doneAt = t + int(math.Ceil(secs-1e-9))
return true
}
// deploy stakes an idle tool on the moon with the most free ore.
@@ -471,7 +463,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
// Redeploy: the same well for a one-well drill, the same moon for the others.
if len(prev) > 0 {
only := -1
if c.StoreItems[tl.item].ToolReach(tl.tier) == 1 {
if c.StoreItems[tl.item].ToolReach() == 1 {
only = prev[0].well
}
book(t, tl, prev[0].moon, only)
@@ -489,7 +481,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
pressure[name] = c.MarketPressure.Decay(pressure[name], 1)
}
if t-lastSell >= gsSellEvery {
sell := c.bonusesFor(advisors, nil).Sell
sell := c.bonusesFor(advisors).Sell
tick := int64(t / 300)
left := false
for _, name := range resources {
@@ -556,7 +548,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
row := GSRow{At: SimTime(t), Credits: credits, Tools: len(tools), Grade: c.MoonGrades[grade].Grade,
Income: (earned - lastEarned) * 3600 / dt, PerClick: (earned - lastEarned) / float64(max(1, clicks-lastClicks)),
ClickMin: float64(clicks-lastClicks) * 60 / dt, Best: map[string]GSBuy{}, BestKind: best("", pl.All).Kind}
for _, k := range []string{"copy", "upgrade", "attachment", "advisor"} {
for _, k := range []string{"copy", "upgrade", "advisor"} {
row.Best[k] = best(k, true)
}
res.Rows = append(res.Rows, row)
@@ -575,8 +567,8 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
break
}
cost := 1.0
if b.Kind == "attachment" || (b.Kind == "advisor" && len(advisors) >= seats) {
cost = 2 // buy and equip, buy a seat and hire
if b.Kind == "advisor" && len(advisors) >= seats {
cost = 2 // buy a seat and hire
}
if bank < cost {
wantBuy = true
@@ -594,10 +586,8 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
case "copy":
tools = append(tools, gsTool{item: b.tool, doneAt: -1})
case "upgrade":
tools[b.tool].tier++
case "attachment":
name := b.Name[:strings.Index(b.Name, " on ")]
tools[b.tool].mods = append(slices.Clone(tools[b.tool].mods), name)
lv := tools[b.tool].lv
tools[b.tool].lv = lv.With(b.track, lv.Of(b.track)+1)
case "advisor":
if len(advisors) >= seats {
seats++
@@ -626,8 +616,8 @@ func printGradeSim(c *GameConfig) {
for _, pl := range gsPlayers {
r := simulatePlayer(c, pl, stats)
fmt.Printf("\n%s player\n", pl.Name)
fmt.Printf("%7s %9s %9s %6s %5s %5s %3s %-26s %-26s %-34s %-28s %s\n", "at", "credits", "₥/h", "₥/clk", "clk/m", "tools", "gr",
"best copy (/min, /clk)", "best upgrade", "best attachment", "best advisor", "buys")
fmt.Printf("%7s %9s %9s %6s %5s %5s %3s %-26s %-28s %-28s %s\n", "at", "credits", "₥/h", "₥/clk", "clk/m", "tools", "gr",
"best copy (/min, /clk)", "best upgrade", "best advisor", "buys")
cell := func(b GSBuy) string {
if b.Kind == "" {
return "-"
@@ -635,9 +625,9 @@ func printGradeSim(c *GameConfig) {
return fmt.Sprintf("%s %.3g %.3g", b.Name, b.PerMin(), b.PerClick())
}
for _, row := range r.Rows {
fmt.Printf("%7s %9.0f %9.0f %6.0f %5.1f %5d %3s %-26s %-26s %-34s %-28s %s\n", row.At, row.Credits, row.Income,
fmt.Printf("%7s %9.0f %9.0f %6.0f %5.1f %5d %3s %-26s %-28s %-28s %s\n", row.At, row.Credits, row.Income,
row.PerClick, row.ClickMin, row.Tools, row.Grade, cell(row.Best["copy"]), cell(row.Best["upgrade"]),
cell(row.Best["attachment"]), cell(row.Best["advisor"]), row.BestKind)
cell(row.Best["advisor"]), row.BestKind)
}
fmt.Print("first moon of each grade (clicks by then):")
for _, g := range c.MoonGrades {
@@ -654,7 +644,7 @@ func printGradeSim(c *GameConfig) {
fmt.Printf("\nlongest stretch with nothing to click: %s (from %s); with nothing to buy: %s (from %s), before the top grade %s\nbought: %v\n",
r.Idle, r.IdleAt, r.BuyGap, r.BuyGapAt, r.BuyGapPreS, r.Bought)
fmt.Print("purchases (count, ₥ spent, ₥/min and ₥/click added per 1k₥):")
for _, k := range []string{"copy", "upgrade", "attachment", "advisor"} {
for _, k := range []string{"copy", "upgrade", "advisor"} {
if sp := r.Spent[k]; sp > 0 {
fmt.Printf(" %s %d %s %.3g %.3g;", k, r.Bought[k], formatCompact(sp), r.GainMin[k]/sp*1000, r.GainClick[k]/sp*1000)
}
@@ -664,8 +654,8 @@ func printGradeSim(c *GameConfig) {
}
// printBoostTable compares, for each drill alone on the grade it's meant for, a second copy
// with its boosts: what each adds per 1k₥, in ₥/minute and ₥/click; boosts as a ratio to
// the copy.
// with its upgrades: what each adds per 1k₥, in ₥/minute and ₥/click; upgrades as a ratio
// to the copy: each track's first level, the whole track, and both tracks to the top.
func printBoostTable(c *GameConfig, stats []GradeStat) {
capsBy := map[int]ReachCaps{}
caps := func(g int) ReachCaps {
@@ -674,33 +664,41 @@ func printBoostTable(c *GameConfig, stats []GradeStat) {
}
return capsBy[g]
}
fmt.Printf("\na drill alone on its grade: a copy (₥/min, ₥/click per 1k₥), boosts ÷ copy (₥/min, ₥/click)\n%-9s %2s %13s %13s", "drill", "gr", "copy", "upgrades t1-5")
for _, m := range c.Attachments.Items {
fmt.Printf(" %13s", m.Name)
}
fmt.Println()
fmt.Printf("\na drill alone on its grade: a copy (₥/min, ₥/click per 1k₥), upgrades ÷ copy (₥/min, ₥/click)\n%-9s %2s %13s %15s %15s %15s %15s %15s\n",
"drill", "gr", "copy", "yield 1", "yield all", "cycle 1", "cycle all", "both all")
for i := range c.StoreItems {
it := &c.StoreItems[i]
g := c.gradeIndex(suitedGrade(c, it).Grade)
base := []gsGroup{{i, 0, nil, 1, 0}}
inc0, clk0 := gsEval(c, stats, caps, g, base, nil)
inc0, clk0 := gsEval(c, stats, caps, g, []gsGroup{{i, Levels{}, 1, 0}}, nil)
per := func(gs []gsGroup, cost float64) (float64, float64) {
inc, clk := gsEval(c, stats, caps, g, gs, nil)
return (inc - inc0) / 60 / cost * 1000, (inc/clk - inc0/clk0) / cost * 1000
}
cm, cc := per([]gsGroup{{i, 0, nil, 2, 0}}, it.Price)
cm, cc := per([]gsGroup{{i, Levels{}, 2, 0}}, it.Price)
fmt.Printf("%-9s %2s %6.3g %6.3g", toolCode(it.Name), c.MoonGrades[g].Grade, cm, cc)
cost, top := 0.0, 0
for _, u := range it.Upgrades {
cost += u.Price + partsValue(c, u.Parts)
top = max(top, u.Tier)
cost := func(kind string, upTo int) float64 {
sum := 0.0
for n := 1; n <= upTo; n++ {
if l := it.TrackLevel(kind, n); l != nil {
sum += l.Price + partsValue(c, l.Parts)
}
}
return sum
}
if top > 0 {
m, k := per([]gsGroup{{i, top, nil, 1, 0}}, cost)
fmt.Printf(" ×%5.2f ×%5.2f", m/cm, k/cc)
}
for _, a := range c.Attachments.Items {
m, k := per([]gsGroup{{i, 0, []string{a.Name}, 1, 0}}, a.Price)
top := it.MaxLevels()
for _, v := range []struct {
lv Levels
cost float64
}{
{Levels{Yield: 1}, cost("yield", 1)}, {Levels{Yield: top.Yield}, cost("yield", top.Yield)},
{Levels{Cycle: 1}, cost("cycle", 1)}, {Levels{Cycle: top.Cycle}, cost("cycle", top.Cycle)},
{top, cost("yield", top.Yield) + cost("cycle", top.Cycle)},
} {
if v.cost <= 0 {
fmt.Printf(" %13s", "-")
continue
}
m, k := per([]gsGroup{{i, v.lv, 1, 0}}, v.cost)
fmt.Printf(" ×%5.2f ×%5.2f", m/cm, k/cc)
}
fmt.Println()
+1
View File
@@ -28,6 +28,7 @@ func logEvent(ctx context.Context, q querier, userID, kind string, data map[stri
}
// Credits spent are logged as "price" on these kinds, earned as "credits" on sell.
// buy_attachment: attachments were sold until migration 020 (kept for older events).
const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'buy_attachment', 'hire_advisor', 'buy_seat'"
// ------------------------------------------------------------------ dashboard
+4 -6
View File
@@ -7,13 +7,12 @@ package main
//
// users.onboarding holds flags, which are only ever added:
// - 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 or
// attachment);
// refinery (bought a moon of refinery.unlockGrade or higher), advisors (bought a tool);
// - claimed:<resource>, the resources claimed so far;
// - 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",
// Help, Leaderboard, moon sale, "Claim and redeploy", upgrades, attachments and selling
// Help, Leaderboard, moon sale, "Claim and redeploy", upgrades (tracks.go) and selling
// tools (the drill panel opens with Equipment and shows these parts once the Store is
// unlocked); Claim all also needs 2 tools.
@@ -126,9 +125,8 @@ func markSeen(ctx context.Context, db *pgxpool.Pool, userID, section string) err
// actionGates is the feature each action needs; runAction refuses the rest.
var actionGates = map[string]string{
"sell-moon": "store", "sell-tool": "store", "sell-attachment": "store",
"buy-tool": "store", "upgrade": "store", "buy-attachment": "store",
"equip": "store", "unequip": "store", "redrill": "market", "new-game": "help",
"sell-moon": "store", "sell-tool": "store",
"buy-tool": "store", "upgrade": "store", "redrill": "market", "new-game": "help",
"sell": "market",
"refine": "refinery", "collect": "refinery", "buy-slot": "refinery", "upgrade-batch": "refinery",
"hire": "advisors", "buy-seat": "advisors", "seat-advisor": "advisors", "unseat-advisor": "advisors",
+1 -1
View File
@@ -23,7 +23,7 @@ var odT0 = time.Unix(1_000_000, 0)
func odStake(n int, well string, amount float64, reach int) Stake {
return Stake{ID: fmt.Sprintf("s%d", n), MoonID: "m", WellID: well, Amount: amount, Reach: reach,
Start: odT0.Add(time.Duration(n) * time.Second), DurationInSecs: 10 + n}
Start: odT0.Add(time.Duration(n) * time.Second), DurationInSecs: float64(10 + n)}
}
func near(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
+14 -12
View File
@@ -202,7 +202,7 @@ func (s *server) forbidden(w http.ResponseWriter, name string, data any) {
type PlayerTool struct {
Tool
Item *StoreItem
MaxTier int
Max Levels // the top level of each track
Stake *Stake
MoonName string
Remaining time.Duration
@@ -291,7 +291,7 @@ func (s *server) loadPlayerPage(ctx context.Context, userID, adminID string) (*P
for _, t := range st.Tools {
pt := PlayerTool{Tool: t, Item: st.StoreItem(t.StoreItemID), Stake: st.StakeForTool(t.ID)}
if pt.Item != nil {
pt.MaxTier = len(pt.Item.Upgrades)
pt.Max = pt.Item.MaxLevels()
}
if pt.Stake != nil {
pt.MoonName = st.MoonName(pt.Stake.MoonID)
@@ -402,10 +402,12 @@ func (s *server) handlePlayerAction(w http.ResponseWriter, r *http.Request) {
}
case "tool":
item, _ := strconv.Atoi(r.FormValue("item"))
tier, _ := strconv.Atoi(r.FormValue("tier"))
var lv Levels
lv.Yield, _ = strconv.Atoi(r.FormValue("yield"))
lv.Cycle, _ = strconv.Atoi(r.FormValue("cycle"))
var name string
if name, err = adminTool(ctx, s.db, adminID, id, item, tier); err == nil {
message = fmt.Sprintf("Granted %s (tier %d)", name, tier)
if name, err = adminTool(ctx, s.db, adminID, id, item, lv); err == nil {
message = fmt.Sprintf("Granted %s (yield level %d, cycle level %d)", name, lv.Yield, lv.Cycle)
}
case "finish-stakes":
var n int
@@ -550,8 +552,8 @@ func adminResources(ctx context.Context, db *pgxpool.Pool, adminID, userID, name
})
}
// adminTool gives the player a store item at a tier (0 is the base tool).
func adminTool(ctx context.Context, db *pgxpool.Pool, adminID, userID string, itemID, tier int) (string, error) {
// adminTool gives the player a store item at track levels (0 is the base tool).
func adminTool(ctx context.Context, db *pgxpool.Pool, adminID, userID string, itemID int, lv Levels) (string, error) {
var item *StoreItem
items := cfg().StoreItems
for i := range items {
@@ -562,21 +564,21 @@ func adminTool(ctx context.Context, db *pgxpool.Pool, adminID, userID string, it
if item == nil {
return "", gameErr("Pick a tool")
}
if tier < 0 || tier > len(item.Upgrades) {
return "", gameErr("%s has tiers 0 to %d", item.Name, len(item.Upgrades))
if top := item.MaxLevels(); lv.Yield < 0 || lv.Cycle < 0 || lv.Yield > top.Yield || lv.Cycle > top.Cycle {
return "", gameErr("%s has yield levels 0 to %d and cycle levels 0 to %d", item.Name, top.Yield, top.Cycle)
}
err := inTx(ctx, db, func(tx pgx.Tx) error {
if _, err := lockPlayer(ctx, tx, userID); err != nil {
return err
}
_, err := tx.Exec(ctx,
"insert into inventory_item(user_id, store_item_id, tier) values ($1, $2, $3)",
userID, itemID, tier)
"insert into inventory_item(user_id, store_item_id, yield_level, cycle_level) values ($1, $2, $3, $4)",
userID, itemID, lv.Yield, lv.Cycle)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "admin_grant",
map[string]any{"admin": adminID, "item": itemID, "name": item.Name, "tier": tier})
map[string]any{"admin": adminID, "item": itemID, "name": item.Name, "yieldLevel": lv.Yield, "cycleLevel": lv.Cycle})
})
return item.Name, err
}
+11 -28
View File
@@ -2,9 +2,9 @@ package main
// The refinery turns raw resources into refined items on a timer. A batch (recipe ×
// quantity) takes its inputs when it starts and fills one slot until the player collects
// it. Refined items sell in the market and are parts for higher tool upgrades.
// Recipes, prices, slot prices and upgrade parts come from config/game.json ("refinery",
// and "parts" on upgrade tiers).
// it. Refined items sell in the market and are parts for the higher levels of the drills'
// upgrade tracks (tracks.go). Recipes, prices, slot prices and upgrade parts come from
// config/game.json ("refinery", and "parts" on track levels).
//
// Lock order, shared with game.go: users row (refinery actions) → bank_account →
// resource_account → refined_account.
@@ -168,24 +168,12 @@ func (cfg *GameConfig) validateRefinery() error {
}
}
for _, si := range cfg.StoreItems {
for _, u := range si.Upgrades {
for name, qty := range u.Parts {
if _, ok := tiers[name]; !ok || !(qty > 0) {
return fmt.Errorf("store item %d tier %d: part %q must be a refined item with a positive amount", si.ID, u.Tier, name)
}
}
}
}
return nil
}
// upgradeParts is what upgrading a tool to the given tier uses up, from the config.
func upgradeParts(storeItemID, tier int) map[string]float64 {
for _, item := range cfg().StoreItems {
if item.ID == storeItemID {
for _, u := range item.Upgrades {
if u.Tier == tier {
return u.Parts
for _, kind := range trackKinds {
for i, l := range si.Track(kind).Levels {
for name, qty := range l.Parts {
if _, ok := tiers[name]; !ok || !(qty > 0) {
return fmt.Errorf("store item %d %s level %d: part %q must be a refined item with a positive amount", si.ID, kind, i+1, name)
}
}
}
}
@@ -427,7 +415,7 @@ func startRefine(ctx context.Context, db *pgxpool.Pool, userID, name string, qty
return err
}
// Advisors with a refinery bonus too (bonus.go).
bonus, err := loadBonus(ctx, tx, userID, "")
bonus, err := loadBonus(ctx, tx, userID)
if err != nil {
return err
}
@@ -583,7 +571,7 @@ func sellRefined(ctx context.Context, db *pgxpool.Pool, userID, name string, amo
if err != nil {
return err
}
bonus, err := loadBonus(ctx, tx, userID, "")
bonus, err := loadBonus(ctx, tx, userID)
if err != nil {
return err
}
@@ -687,11 +675,6 @@ func (s *State) partRows(need map[string]float64) []PartRow {
return rows
}
// upgradeParts is the parts an upgrade to the given tier needs, with what the player has.
func (s *State) upgradeParts(storeItemID, tier int) []PartRow {
return s.partRows(upgradeParts(storeItemID, tier))
}
func (s *State) refineryPage() *RefineryPage {
c := cfg()
maxBatch := c.Refinery.maxBatchAt(s.RefineryBatchLevel)
+15 -54
View File
@@ -293,8 +293,8 @@ func sellMoon(ctx context.Context, db *pgxpool.Pool, userID, moonID string) (str
// ------------------------------------------------------------------ equipment
// Tools and attachments sell back for equipmentResale.factor (below 1) of their price in
// credits: a tool for its store price plus the upgrades it has had. Refined parts used by
// Tools sell back for equipmentResale.factor (below 1) of their price in credits: the
// store price plus the track levels it has had (tracks.go). Refined parts used by
// upgrades are not refunded.
type EquipmentResale struct {
Factor float64 `json:"factor"`
@@ -303,19 +303,11 @@ type EquipmentResale struct {
func resaleOf(price float64) float64 { return math.Floor(price * cfg().EquipmentResale.Factor) }
// ToolResale is what a tool sells for: its price and its upgrades' prices, × the factor.
func (item *StoreItem) ToolResale(tier int) float64 {
paid := item.Price
for _, u := range item.Upgrades {
if u.Tier <= tier {
paid += u.Price
}
}
return resaleOf(paid)
func (item *StoreItem) ToolResale(lv Levels) float64 {
return resaleOf(item.Price + item.upgradesPrice(lv))
}
func (a *Attachment) Resale() float64 { return resaleOf(a.Price) }
// sellTool sells one of the player's idle tools. Its attachments go back to the inventory.
// sellTool sells one of the player's idle tools.
func sellTool(ctx context.Context, db *pgxpool.Pool, userID, toolID string) (string, float64, error) {
var name string
var earned float64
@@ -329,17 +321,19 @@ func sellTool(ctx context.Context, db *pgxpool.Pool, userID, toolID string) (str
if err != nil {
return err
}
var storeItemID, tier int
var price, upgrades float64
var storeItemID int
var lv Levels
err = tx.QueryRow(ctx, `
select inventory_item.store_item_id, inventory_item.tier, store_item.name, store_item.price,
(select coalesce(sum(price), 0) from upgrade_item
where store_item_id = inventory_item.store_item_id and tier <= inventory_item.tier)
select inventory_item.store_item_id, inventory_item.yield_level, inventory_item.cycle_level, store_item.name
from inventory_item join store_item on store_item.id = inventory_item.store_item_id
where inventory_item.id = $1`, toolID).Scan(&storeItemID, &tier, &name, &price, &upgrades)
where inventory_item.id = $1`, toolID).Scan(&storeItemID, &lv.Yield, &lv.Cycle, &name)
if err != nil {
return err
}
item := configItem(storeItemID)
if item == nil {
return gameErr("That tool can't be sold")
}
var tools int
err = tx.QueryRow(ctx, "select count(*) from inventory_item where user_id = $1", userID).Scan(&tools)
if err != nil {
@@ -366,11 +360,7 @@ func sellTool(ctx context.Context, db *pgxpool.Pool, userID, toolID string) (str
return gameErr("Claim this drill's haul first")
}
earned = resaleOf(price + upgrades)
tag, err := tx.Exec(ctx, "update attachment set inventory_item_id = null where inventory_item_id = $1", toolID)
if err != nil {
return err
}
earned = item.ToolResale(lv)
if _, err := tx.Exec(ctx, "delete from inventory_item where id = $1", toolID); err != nil {
return err
}
@@ -380,36 +370,7 @@ func sellTool(ctx context.Context, db *pgxpool.Pool, userID, toolID string) (str
return err
}
return logEvent(ctx, tx, userID, "sell_tool", map[string]any{"tool": toolID, "item": storeItemID,
"tier": tier, "credits": earned, "attachments": tag.RowsAffected()})
"yieldLevel": lv.Yield, "cycleLevel": lv.Cycle, "credits": earned})
})
return name, earned, err
}
// sellAttachment sells one of the player's spare (unequipped) attachments of a kind.
func sellAttachment(ctx context.Context, db *pgxpool.Pool, userID, name string) (float64, error) {
a := cfg().attachment(name)
if a == nil {
return 0, gameErr("Unknown attachment")
}
earned := a.Resale()
err := inTx(ctx, db, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
delete from attachment where id = (
select id from attachment
where user_id = $1 and item = $2 and inventory_item_id is null
limit 1 for update skip locked)`, userID, a.Name)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return gameErr("You have no spare %s", a.Name)
}
_, err = tx.Exec(ctx,
"update bank_account set balance = balance + $2 where user_id = $1", userID, earned)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "sell_attachment", map[string]any{"item": a.Name, "credits": earned})
})
return earned, err
}
+7 -30
View File
@@ -384,6 +384,7 @@ type actionResult struct {
View View // the view to show next (the request's, unless the action moves on)
BoughtTool string // a tool just bought (the buy modal)
Upgraded string // a tool just upgraded (its toast)
Track string // the track it upgraded (yield, cycle)
Finished []string // moons a claim mined out
SaleCredits float64 // what a market sale earned (the sell ticket's payout effect)
}
@@ -399,7 +400,7 @@ var errForbidden = errors.New("missing permission")
// unless the action moves on. new-game only passes the gate; the caller creates the player.
func (s *server) runAction(ctx context.Context, userID, action string, get func(string) string) (actionResult, error) {
view := viewFromForm(get)
var message, boughtTool, upgraded string
var message, boughtTool, upgraded, track string
var finished []string
var saleCredits float64
var err error
@@ -430,12 +431,6 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
if name, earned, err = sellTool(ctx, s.db, userID, get("tool")); err == nil {
message = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned))
}
case "sell-attachment":
item := get("item")
var earned float64
if earned, err = sellAttachment(ctx, s.db, userID, item); err == nil {
message = fmt.Sprintf("Sold %s for %s", item, formatCredits(earned))
}
case "buy-tool":
id, _ := strconv.Atoi(get("id"))
if boughtTool, err = buyTool(ctx, s.db, userID, id); err == nil {
@@ -443,7 +438,9 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
}
case "upgrade":
var tool string
if tool, err = upgradeTool(ctx, s.db, userID, get("tool")); err == nil {
// Raises one of the tool's upgrade tracks (track=yield|cycle) a level (tracks.go).
track = get("track")
if tool, err = upgradeTool(ctx, s.db, userID, get("tool"), track); err == nil {
message, upgraded = "Tool upgraded", tool
view.Drill = followTool(view.Drill, get("tool"), tool)
}
@@ -548,26 +545,6 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
if n, err = upgradeRefineryBatch(ctx, s.db, userID); err == nil {
message = fmt.Sprintf("Refinery batches up to %d", n)
}
case "buy-attachment":
item := get("item")
if err = buyAttachment(ctx, s.db, userID, item); err == nil {
message = item + " added to your inventory"
}
case "equip", "unequip":
// Puts an attachment (item, by name) on a tool (to), or takes it off.
// A busy tool's idle copy may get it instead; the panel follows that one.
item, tool := get("item"), get("to")
var used string
if action == "equip" {
used, err = equipAttachment(ctx, s.db, userID, item, tool)
message = item + " equipped"
} else {
used, err = unequipAttachment(ctx, s.db, userID, item, tool)
message = item + " unequipped"
}
if err == nil {
view.Drill = followTool(view.Drill, tool, used)
}
case "hire":
name := get("advisor")
if err = hireAdvisor(ctx, s.db, userID, name); err == nil {
@@ -623,7 +600,7 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
return actionResult{}, err
}
return actionResult{Action: action, Err: err, Message: message, View: view, BoughtTool: boughtTool,
Upgraded: upgraded, Finished: finished, SaleCredits: saleCredits}, nil
Upgraded: upgraded, Track: track, Finished: finished, SaleCredits: saleCredits}, nil
}
// toast is the action's toast, for the state after it.
@@ -633,7 +610,7 @@ func (res actionResult) toast(state *State) *Toast {
}
toast := &Toast{Message: res.Message, Kind: res.Action, Finished: strings.Join(res.Finished, ","), Credits: res.SaleCredits}
if t := state.tool(res.Upgraded); t != nil {
toast.Message = state.upgradedText(*t)
toast.Message = state.upgradedText(*t, res.Track)
}
return toast
}
+80 -92
View File
@@ -14,7 +14,8 @@ import (
const (
// A bigger tool should earn at least this many times the previous one per hour.
minRateStep = 1.2
// An upgrade shouldn't take more than this many times as long to pay back as its tool.
// An upgrade (a track level) shouldn't take more than this many times as long to pay
// back as its tool.
maxUpgradePayback = 2.0
// Payback time shouldn't jump by more than this between consecutive tools.
maxPaybackJump = 4.0
@@ -88,15 +89,18 @@ func (rc ReachCaps) Yield(amount float64, reach int) float64 {
return (r.sums[i] + amount*float64(n-i)) / float64(n)
}
type TierStat struct {
Tier int
// LevelStat is a level of a tool's upgrade track, the other track at 0.
type LevelStat struct {
Track string // yield, cycle
Level int
Price float64
PartsValue float64 // sell value of the refined parts it uses up
Yield float64
Secs float64
Reach int
Eff float64 // what a claim gets on average from a fresh moon (ReachCaps)
CreditsPerHour float64 // at Eff
Payback Hours // for the upgrade's extra income to repay its price plus parts
Payback Hours // for the level's extra income (over the one below) to repay its price plus parts
TooBig bool // yield above what its reach can hold (Capacity)
Capacity float64
}
@@ -118,7 +122,7 @@ type ToolStat struct {
TooBig bool
RatioLow bool
JumpHigh bool
Tiers []TierStat
Levels []LevelStat // both tracks, yield first
}
type Analysis struct {
@@ -175,7 +179,7 @@ func avgWells(c *GameConfig, g *MoonGrade) float64 {
// closest to 6.5 claims (each of its reach wells gets a share of a claim).
func suitedGrade(c *GameConfig, item *StoreItem) *MoonGrade {
best, bestD := &c.MoonGrades[0], math.Inf(1)
perWell := item.ClaimAmount / float64(item.ToolReach(0))
perWell := item.ClaimAmount / float64(item.ToolReach())
for i := range c.MoonGrades {
g := &c.MoonGrades[i]
if d := math.Abs(math.Log(g.MeanSupply() / max(1e-9, perWell) / 6.5)); d < bestD {
@@ -195,11 +199,11 @@ func configItemIn(c *GameConfig, id int) *StoreItem {
return &StoreItem{ClaimAmount: 1}
}
func perHour(units float64, secs int) float64 {
func perHour(units, secs float64) float64 {
if secs <= 0 {
return 0
}
return units * 3600 / float64(secs)
return units * 3600 / secs
}
// Hours is a duration in hours; Inf for never.
@@ -263,9 +267,9 @@ func analyze(c *GameConfig, errs []string, unsaved int) *Analysis {
for i, item := range items {
caps := reachCaps(c, suitedGrade(c, &item))
t := ToolStat{ID: item.ID, Name: item.Name, Price: item.Price, Claim: item.ClaimAmount,
Secs: item.CompletionTimeInSecs, Reach: item.ToolReach(0)}
Secs: item.CompletionTimeInSecs, Reach: item.ToolReach()}
t.Eff = caps.Yield(item.ClaimAmount, t.Reach)
t.UnitsPerHour = perHour(t.Eff, item.CompletionTimeInSecs)
t.UnitsPerHour = perHour(t.Eff, float64(item.CompletionTimeInSecs))
t.CreditsPerHour = t.UnitsPerHour * a.AvgPrice
t.Payback = hours(item.Price, t.CreditsPerHour)
maxWell := wellMax(item.ID)
@@ -283,33 +287,34 @@ func analyze(c *GameConfig, errs []string, unsaved int) *Analysis {
t.JumpHigh = t.PaybackJump > maxPaybackJump
}
}
for _, u := range item.Upgrades {
yield, reach := item.ClaimAmount+u.ClaimBoost, item.ToolReach(u.Tier)
eff := caps.Yield(yield, reach)
rate := perHour(eff, item.ToolSecs(u.Tier)) * a.AvgPrice
for _, kind := range trackKinds {
prevRate := t.CreditsPerHour
if len(t.Tiers) > 0 {
prevRate = t.Tiers[len(t.Tiers)-1].CreditsPerHour
for n, u := range item.Track(kind).Levels {
lv := Levels{}.With(kind, n+1)
yield, secs, reach := item.ToolYield(lv), item.ToolSecs(lv), item.ToolReach()
eff := caps.Yield(yield, reach)
rate := perHour(eff, secs) * a.AvgPrice
parts := partsValue(c, u.Parts)
t.Levels = append(t.Levels, LevelStat{Track: kind, Level: n + 1, Price: u.Price, PartsValue: parts, Yield: yield,
Secs: secs, Reach: reach, Eff: eff, CreditsPerHour: rate, Payback: hours(u.Price+parts, rate-prevRate),
Capacity: capacity(reach), TooBig: yield > capacity(reach)})
prevRate = rate
}
parts := partsValue(c, u.Parts)
t.Tiers = append(t.Tiers, TierStat{Tier: u.Tier, Price: u.Price, PartsValue: parts, Yield: yield,
Reach: reach, Eff: eff, CreditsPerHour: rate, Payback: hours(u.Price+parts, rate-prevRate),
Capacity: capacity(reach), TooBig: yield > capacity(reach)})
}
// An upgrade should be worth its price next to buying the next tool, and pay back
// about as fast as the tool itself.
for _, u := range t.Tiers {
for _, u := range t.Levels {
if i+1 < len(items) {
next := &items[i+1]
nextRate := perHour(caps.Yield(next.ClaimAmount, next.ToolReach(0)), next.CompletionTimeInSecs) * a.AvgPrice
nextRate := perHour(caps.Yield(next.ClaimAmount, next.ToolReach()), float64(next.CompletionTimeInSecs)) * a.AvgPrice
if u.Price+u.PartsValue > next.Price && u.CreditsPerHour < nextRate {
a.warn(fmt.Sprintf("item:%d", item.ID), fmt.Sprintf("%s tier %d costs %s, more than %s (%s), and earns less.",
item.Name, u.Tier, formatCredits(u.Price+u.PartsValue), next.Name, formatCredits(next.Price)))
a.warn(fmt.Sprintf("item:%d", item.ID), fmt.Sprintf("%s %s level %d costs %s, more than %s (%s), and earns less.",
item.Name, u.Track, u.Level, formatCredits(u.Price+u.PartsValue), next.Name, formatCredits(next.Price)))
}
}
if u.Payback > maxUpgradePayback*t.Payback {
a.warn(fmt.Sprintf("item:%d", item.ID), fmt.Sprintf("%s tier %d pays back in %s, over %.0f× the tool itself (%s).",
item.Name, u.Tier, u.Payback, maxUpgradePayback, t.Payback))
a.warn(fmt.Sprintf("item:%d", item.ID), fmt.Sprintf("%s %s level %d pays back in %s, over %.0f× the tool itself (%s).",
item.Name, u.Track, u.Level, u.Payback, maxUpgradePayback, t.Payback))
}
}
key := fmt.Sprintf("item:%d", item.ID)
@@ -317,10 +322,10 @@ func analyze(c *GameConfig, errs []string, unsaved int) *Analysis {
a.warn(key, fmt.Sprintf("%s claims %s, more than %s can hold (%s).",
item.Name, formatMoney(item.ClaimAmount), reachText(t.Reach), formatMoney(t.Capacity)))
} else {
for _, u := range t.Tiers {
for _, u := range t.Levels {
if u.TooBig {
a.warn(key, fmt.Sprintf("%s at tier %d claims %s, more than %s can hold (%s).",
item.Name, u.Tier, formatMoney(u.Yield), reachText(u.Reach), formatMoney(u.Capacity)))
a.warn(key, fmt.Sprintf("%s at yield level %d claims %s, more than %s can hold (%s).",
item.Name, u.Level, formatMoney(u.Yield), reachText(u.Reach), formatMoney(u.Capacity)))
break
}
}
@@ -395,21 +400,21 @@ type SimSnapshot struct {
}
type SimResult struct {
Hours int
Reaction int // seconds between the player's actions
Tools []SimTool
Attachments []SimBuy // equipped, by kind
Advisors []SimBuy // hired
Seats int // advisor seats at the end
Moons []SimMoon
Snapshots []SimSnapshot
Events []string
MoreEvents int
Capped bool
CappedAt SimTime
Hours int
Reaction int // seconds between the player's actions
Tools []SimTool
Upgrades int // track levels bought
Advisors []SimBuy // hired
Seats int // advisor seats at the end
Moons []SimMoon
Snapshots []SimSnapshot
Events []string
MoreEvents int
Capped bool
CappedAt SimTime
}
// SimBuy is an attachment or advisor the sim player bought: when first, and how many.
// SimBuy is an advisor the sim player bought: when first, and how many.
type SimBuy struct {
Name string
BoughtAt SimTime // -1 for never
@@ -418,8 +423,7 @@ type SimBuy struct {
type simTool struct {
item int // index into the config's store items
tier int
mods []string // attachments equipped
lv Levels
busy bool
doneAt int
amount float64
@@ -428,8 +432,8 @@ type simTool struct {
// simulate plays a greedy, active player: starting config, sells everything at the average
// price, and every simReaction seconds claims and redeploys every finished drill, buys a
// moon when its wells can't take two more rounds from every drill, and otherwise buys
// what breaks even soonest (time to save up plus payback): a tool, an upgrade, an
// attachment for one tool or an advisor (with a seat if needed), keeping enough for the next moon.
// what breaks even soonest (time to save up plus payback): a tool, a track level for one
// tool or an advisor (with a seat if needed), keeping enough for the next moon.
func simulate(c *GameConfig) *SimResult {
price := avgPrice(c)
rng := rand.New(rand.NewPCG(simSeed[0], simSeed[1]))
@@ -440,29 +444,26 @@ func simulate(c *GameConfig) *SimResult {
itemIndex[item.ID] = i
res.Tools = append(res.Tools, SimTool{Name: item.Name, Price: item.Price, AffordAt: -1, BoughtAt: -1})
}
for _, m := range c.Attachments.Items {
res.Attachments = append(res.Attachments, SimBuy{Name: m.Name, BoughtAt: -1})
}
for _, a := range c.Advisors.Items {
res.Advisors = append(res.Advisors, SimBuy{Name: a.Name, BoughtAt: -1})
}
var advisors []string // hired
// A tool's cycle and yield with its attachments and the given advisors (bonus.go). A drill
// A tool's cycle and yield at its levels with the given advisors (bonus.go). A drill
// that finishes between the player's actions waits for the next one.
stats := func(item, tier int, mods, adv []string) (int, float64) {
stats := func(item int, lv Levels, adv []string) (int, float64) {
it := &c.StoreItems[item]
secs, y := drillStats(it.ToolSecs(tier), it.ToolYield(tier), c.bonusesFor(adv, mods), 1, 1)
return (secs + simReaction - 1) / simReaction * simReaction, y
secs, y := drillStats(it.ToolSecs(lv), it.ToolYield(lv), c.bonusesFor(adv), 1, 1)
return int(math.Ceil(secs/simReaction-1e-9)) * simReaction, y
}
// rate is a tool's credits per hour, with what its reach gets from a fresh moon.
capsBy := map[string]ReachCaps{}
rate := func(item, tier int, mods, adv []string) float64 {
secs, y := stats(item, tier, mods, adv)
rate := func(item int, lv Levels, adv []string) float64 {
secs, y := stats(item, lv, adv)
g := suitedGrade(c, &c.StoreItems[item])
if capsBy[g.Grade] == nil {
capsBy[g.Grade] = reachCaps(c, g)
}
return perHour(capsBy[g.Grade].Yield(y, c.StoreItems[item].ToolReach(tier)), secs) * price * c.bonusesFor(adv, nil).Sell
return perHour(capsBy[g.Grade].Yield(y, c.StoreItems[item].ToolReach()), float64(secs)) * price * c.bonusesFor(adv).Sell
}
// The grade of the next moon: the dearest one the player could buy twice.
nextGrade := func(credits float64) *MoonGrade {
@@ -474,9 +475,9 @@ func simulate(c *GameConfig) *SimResult {
}
return g
}
reach := func(t *simTool) int { return c.StoreItems[t.item].ToolReach(t.tier) }
yield := func(t *simTool) float64 { _, y := stats(t.item, t.tier, t.mods, advisors); return y }
cycle := func(t *simTool) int { secs, _ := stats(t.item, t.tier, t.mods, advisors); return secs }
reach := func(t *simTool) int { return c.StoreItems[t.item].ToolReach() }
yield := func(t *simTool) float64 { _, y := stats(t.item, t.lv, advisors); return y }
cycle := func(t *simTool) int { secs, _ := stats(t.item, t.lv, advisors); return secs }
credits := c.StartingCredits + c.StartingResources*price*float64(len(c.ResourcePrices))
earned, earnedAtSnap, snapAt, moonsAtSnap := 0.0, 0.0, 0, 0
@@ -493,7 +494,7 @@ func simulate(c *GameConfig) *SimResult {
income := func(adv []string) float64 {
sum := 0.0
for i := range tools {
sum += rate(tools[i].item, tools[i].tier, tools[i].mods, adv)
sum += rate(tools[i].item, tools[i].lv, adv)
}
return sum
}
@@ -526,7 +527,7 @@ func simulate(c *GameConfig) *SimResult {
act := t%simReaction == 0
// Claim finished drills (and sell at once, with the advisors' sell bonus and selling
// pressure, spread evenly over the resources).
sell := c.bonusesFor(advisors, nil).Sell
sell := c.bonusesFor(advisors).Sell
if act {
pressure = c.MarketPressure.Decay(pressure, simReaction)
units := 0.0
@@ -599,17 +600,17 @@ func simulate(c *GameConfig) *SimResult {
event(t, "moon #%d (%s)", moons, formatCredits(moonPrice))
continue
}
// Soonest break-even (time to save up plus payback): a new tool; the next tier or
// an attachment for one owned tool of each kind; or an advisor.
// Soonest break-even (time to save up plus payback): a new tool; the next level of
// either track for one owned tool of each kind; or an advisor.
now := income(advisors)
breakEven := func(cost, gain float64) Hours {
return hours(max(0, cost-credits), now) + hours(cost, gain)
}
type choice struct {
kind string // tool, upgrade, attachment, advisor
kind string // tool, upgrade, advisor
item int // store item index (tool)
tool int // index into tools (upgrade, attachment)
name string // attachment or advisor
tool int // index into tools (upgrade)
name string // advisor; upgrade: the track
seat float64
cost float64
payback Hours
@@ -622,7 +623,7 @@ func simulate(c *GameConfig) *SimResult {
}
if len(tools) < simMaxTools {
for i, item := range c.StoreItems {
consider(choice{kind: "tool", item: i, cost: item.Price}, rate(i, 0, nil, advisors))
consider(choice{kind: "tool", item: i, cost: item.Price}, rate(i, Levels{}, advisors))
}
} else {
capped(t)
@@ -630,27 +631,19 @@ func simulate(c *GameConfig) *SimResult {
seen := map[string]bool{}
for i := range tools {
tl := &tools[i]
key := fmt.Sprint(tl.item, tl.tier, tl.mods)
key := fmt.Sprint(tl.item, tl.lv)
if seen[key] {
continue
}
seen[key] = true
item := &c.StoreItems[tl.item]
cur := rate(tl.item, tl.tier, tl.mods, advisors)
if up := item.NextUpgrade(tl.tier); up != nil {
consider(choice{kind: "upgrade", item: tl.item, tool: i, cost: up.Price + partsValue(c, up.Parts)},
rate(tl.item, tl.tier+1, tl.mods, advisors)-cur)
}
if len(tl.mods) >= c.Attachments.SlotsPerTool {
continue
}
for _, m := range c.Attachments.Items {
if slices.Contains(tl.mods, m.Name) {
continue
cur := rate(tl.item, tl.lv, advisors)
for _, kind := range trackKinds {
n := tl.lv.Of(kind) + 1
if up := item.TrackLevel(kind, n); up != nil {
consider(choice{kind: "upgrade", item: tl.item, tool: i, name: kind, cost: up.Price + partsValue(c, up.Parts)},
rate(tl.item, tl.lv.With(kind, n), advisors)-cur)
}
mods := append(slices.Clone(tl.mods), m.Name)
consider(choice{kind: "attachment", item: tl.item, tool: i, name: m.Name, cost: m.Price},
rate(tl.item, tl.tier, mods, advisors)-cur)
}
}
for _, a := range c.Advisors.Items {
@@ -674,8 +667,11 @@ func simulate(c *GameConfig) *SimResult {
credits -= best.cost
switch best.kind {
case "upgrade":
tools[best.tool].tier++
event(t, "upgrade %s to tier %d (%s)", c.StoreItems[best.item].Name, tools[best.tool].tier, formatCredits(best.cost))
lv := tools[best.tool].lv
tools[best.tool].lv = lv.With(best.name, lv.Of(best.name)+1)
res.Upgrades++
event(t, "upgrade %s %s to level %d (%s)", c.StoreItems[best.item].Name, best.name,
tools[best.tool].lv.Of(best.name), formatCredits(best.cost))
case "tool":
tools = append(tools, simTool{item: best.item})
if res.Tools[best.item].BoughtAt < 0 {
@@ -683,14 +679,6 @@ func simulate(c *GameConfig) *SimResult {
}
res.Tools[best.item].Count++
event(t, "buy %s (%s)", c.StoreItems[best.item].Name, formatCredits(best.cost))
case "attachment":
tools[best.tool].mods = append(slices.Clone(tools[best.tool].mods), best.name)
for i := range res.Attachments {
if res.Attachments[i].Name == best.name {
bought(&res.Attachments[i], t)
}
}
event(t, "equip %s on %s (%s)", best.name, c.StoreItems[best.item].Name, formatCredits(best.cost))
case "advisor":
if best.seat > 0 {
res.Seats++
@@ -0,0 +1,48 @@
-- Drill upgrade tracks (tracks.go) replace the upgrade tiers and the attachments. Every step
-- checks what is there first, so running it again (or on a fresh database) does nothing.
-- - Each tool gets a level on its yield and its cycle track. A tool's old tier N becomes
-- cycle level N (the tiers made cycles faster; every cycle track has 5 levels), yield 0.
-- - Attachments are refunded at what they cost (Turbo Core 900, Diamond Bit 950, Cryo
-- Cooler 85,000, Seam Scanner 90,000, the store prices when they were removed), spare
-- or equipped, logged as refund_attachments; the table goes.
-- - upgrade_item (the tiers' prices) goes: the tracks are read from the game config.
-- - Stakes keep their cycle to a tenth of a second (cycle levels such as 2.6 s).
alter table inventory_item add column if not exists yield_level integer not null default 0
check (yield_level >= 0);
alter table inventory_item add column if not exists cycle_level integer not null default 0
check (cycle_level >= 0);
do $$
begin
if exists (select 1 from information_schema.columns
where table_name = 'inventory_item' and column_name = 'tier') then
update inventory_item set cycle_level = least(tier, 5) where tier > 0;
alter table inventory_item drop column tier;
end if;
if exists (select 1 from information_schema.tables where table_name = 'attachment') then
with refund as (
select user_id, sum(case item when 'Turbo Core' then 900 when 'Diamond Bit' then 950
when 'Cryo Cooler' then 85000 when 'Seam Scanner' then 90000
else 0 end) as credits, count(*) as n
from attachment group by user_id
), paid as (
update bank_account set balance = balance + refund.credits
from refund where bank_account.user_id = refund.user_id and refund.credits > 0
returning bank_account.user_id, refund.credits, refund.n
)
insert into event_log(user_id, kind, data)
select user_id, 'refund_attachments', jsonb_build_object('refund', credits, 'attachments', n) from paid;
drop table attachment;
end if;
if exists (select 1 from information_schema.columns
where table_name = 'staking_event' and column_name = 'duration_in_secs'
and data_type <> 'double precision') then
alter table staking_event alter column duration_in_secs type float8;
end if;
end
$$;
drop table if exists upgrade_item;
+6 -25
View File
@@ -28,16 +28,6 @@ create table store_item(
completion_time_in_secs integer not null
);
create table upgrade_item(
tier integer not null,
store_item_id integer not null,
price float8 not null,
claim_boost float8 not null,
constraint fk_store_item_upgrade_item foreign key(store_item_id)
references store_item(id) on delete cascade,
primary key (store_item_id, tier)
);
create table staking_source (
id uuid primary key default gen_random_uuid(),
name varchar not null,
@@ -66,9 +56,13 @@ create table resource_well (
create table inventory_item (
id uuid primary key default gen_random_uuid(),
user_id uuid not null,
tier integer not null default 0,
store_item_id int not null,
created_at timestamp with time zone default now(),
-- Levels on the tool's upgrade tracks (tracks.go; the values are in the game config).
yield_level integer not null default 0
check (yield_level >= 0),
cycle_level integer not null default 0
check (cycle_level >= 0),
constraint fk_user_inventory_item foreign key(user_id)
references users(id) on delete cascade,
constraint fk_store_item_inventory_item foreign key(store_item_id)
@@ -88,7 +82,7 @@ create table staking_event (
user_id uuid not null,
well_id uuid not null,
inventory_item_id uuid not null,
duration_in_secs integer not null,
duration_in_secs float8 not null, -- to a tenth of a second
stake_amount float8 not null,
created_at timestamp with time zone default now(),
-- Wells of the moon it mines (StoreItem.Reach), fixed at stake time; well_id is the
@@ -227,19 +221,6 @@ create table market_pressure (
primary key (user_id, item)
);
-- Drill attachments (attachments.go) a player owns, by config name; inventory_item_id is
-- the tool it's equipped on (null: in the inventory). At most one of each kind per tool.
create table attachment (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
item text not null,
inventory_item_id uuid references inventory_item(id) on delete set null,
created_at timestamp with time zone not null default now()
);
create index attachment_user on attachment (user_id);
create unique index attachment_once on attachment (inventory_item_id, item)
where inventory_item_id is not null;
-- Advisors hired (advisors.go), by config name. Seated ones (seat 1..users.advisor_seats)
-- give their bonus; seat null: hired but not seated.
create table advisor_hire (
+15 -12
View File
@@ -75,7 +75,7 @@ func TestTemplates(t *testing.T) {
{ID: 2, Kind: "admin_grant", Data: "{}", At: now}}, Page: 1, More: true}
stake := s.Stakes[0]
playerPage := &PlayerPage{Allowed: true, Self: true, P: player, State: s, Running: 1, Events: events,
Tools: []PlayerTool{{Tool: s.Tools[0], Item: &s.Config.StoreItems[0], MaxTier: 3, Stake: &stake,
Tools: []PlayerTool{{Tool: s.Tools[0], Item: &s.Config.StoreItems[0], Max: Levels{4, 5}, Stake: &stake,
MoonName: "Moon", Remaining: time.Minute}, {Tool: s.Tools[1], Item: &s.Config.StoreItems[1]}},
StoreItems: s.Config.StoreItems, Grantable: []string{"Sollux"}, Flash: "Done", FlashError: true}
playersPage := &PlayersPage{Allowed: true, Query: "x", Sort: "seen", Players: []PlayerRow{player}, Total: 1}
@@ -198,8 +198,7 @@ func testID(prefix string, n int) string {
func newPlayerState() *State {
c := cfg()
s := &State{UserID: testID("u", 1), Config: Config{StoreItems: c.StoreItems}, Now: time.Now(),
Balance: c.StartingCredits, LastWell: map[string]string{}, Fitted: map[string][]string{},
Spare: map[string]int{}}
Balance: c.StartingCredits, LastWell: map[string]string{}}
for _, name := range sortedKeys(c.ResourcePrices) {
s.Config.Resources = append(s.Config.Resources, name)
s.Resources = append(s.Resources, ResourceBalance{Resource: name})
@@ -211,7 +210,7 @@ func newPlayerState() *State {
}
// veteranState has everything unlocked and something of each kind: moons (one mined out),
// drills ready, mining and multi-well, idle tools, attachments, advisors, refinery batches,
// drills ready, mining and multi-well, idle and upgraded tools, advisors, refinery batches,
// live boosts and selling pressure.
func veteranState() *State {
c := cfg()
@@ -239,7 +238,15 @@ func veteranState() *State {
for i := range c.StoreItems {
item := &c.StoreItems[i]
for n := range 2 {
s.Tools = append(s.Tools, Tool{ID: testID("t", i*10+n), StoreItemID: item.ID, Tier: min(n, len(item.Upgrades))})
// The second copy upgraded: one level on each track, the top on yield for the first item.
tool := Tool{ID: testID("t", i*10+n), StoreItemID: item.ID}
if n == 1 {
tool.YieldLevel, tool.CycleLevel = min(1, len(item.Tracks.Yield.Levels)), min(1, len(item.Tracks.Cycle.Levels))
if i == 0 {
tool.YieldLevel = len(item.Tracks.Yield.Levels)
}
}
s.Tools = append(s.Tools, tool)
}
}
// Ready, mining and multi-well stakes; the second copy of each tool stays idle.
@@ -254,8 +261,8 @@ func veteranState() *State {
start = now.Add(-time.Hour)
}
s.Stakes = append(s.Stakes, Stake{ID: testID("s", i), MoonID: s.Moons[0].ID, WellID: w.ID, ToolID: tool.ID,
Resource: w.Resource, Amount: item.ToolYield(tool.Tier), Start: start,
DurationInSecs: item.CompletionTimeInSecs, Reach: item.ToolReach(tool.Tier)})
Resource: w.Resource, Amount: item.ToolYield(tool.Levels()), Start: start,
DurationInSecs: item.ToolSecs(tool.Levels()), Reach: item.ToolReach()})
}
// A crowded, over-drilled well: more drills than the wells table draws timer bars for,
// the later ones short or getting nothing.
@@ -265,7 +272,7 @@ func veteranState() *State {
s.Tools = append(s.Tools, tool)
s.Stakes = append(s.Stakes, Stake{ID: testID("s", 900+n), MoonID: s.Moons[0].ID, WellID: wells[1].ID,
ToolID: tool.ID, Resource: wells[1].Resource, Amount: 200, Start: now.Add(time.Duration(n) * time.Second),
DurationInSecs: item.CompletionTimeInSecs, Reach: 1})
DurationInSecs: float64(item.CompletionTimeInSecs), Reach: 1})
}
s.LastWell[s.Tools[1].ID] = wells[1].ID
s.RefinerySlots = 2
@@ -281,10 +288,6 @@ func veteranState() *State {
s.CycleBoosts = PriceBoosts{"all": 2}
s.YieldBoosts = PriceBoosts{first: 2}
s.Pressure = map[string]float64{first: 50}
if len(c.Attachments.Items) > 1 {
s.Fitted[s.Tools[0].ID] = []string{c.Attachments.Items[0].Name}
s.Spare[c.Attachments.Items[1].Name] = 2
}
// Advisors: one seated, one hired but not seated, a free seat.
if len(c.Advisors.Items) > 1 {
s.Hires = []AdvisorHire{{Name: c.Advisors.Items[0].Name, Seat: 1}, {Name: c.Advisors.Items[1].Name}}
+8 -28
View File
@@ -15,7 +15,7 @@ Content-Type: application/x-www-form-urlencoded
userId=:user_id
# A view: s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon, moon=<id>, well=<id>,
# drill=<tool id> (equipment: the tool's attachment panel),
# drill=<tool id> (equipment: the tool's panel, its upgrade tracks),
# pick=<resource or refined item> (market: the selected quote), list=past (moons: the Past tab,
# the only way a mined-out moon shows), page=<n> (with list=past: 10 past moons a page, 1 the
# most recent), adv=<advisor name> (advisors: a hired advisor's panel)
@@ -45,25 +45,20 @@ POST :host/act/sell-moon
:headers
sell-moon=<moon id>
# Sell an idle tool (not the only one); its attachments go back to the inventory
# Sell an idle tool (not the only one)
POST :host/act/sell-tool
:headers
tool=<tool id>
# Sell one spare (unequipped) attachment
POST :host/act/sell-attachment
:headers
item=Turbo Core
# Buy a tool
POST :host/act/buy-tool
:headers
id=1
# Upgrade a tool
# Upgrade a tool: one level on its yield or cycle track (track=yield|cycle)
POST :host/act/upgrade
:headers
tool=2bd5f846-ce1d-45be-998e-26d2e97b7f68
tool=2bd5f846-ce1d-45be-998e-26d2e97b7f68&track=yield
# Deploy a tool on a well
POST :host/act/stake
@@ -114,20 +109,6 @@ POST :host/act/buy-slot
POST :host/act/upgrade-batch
:headers
# Buy a drill attachment (item = config name) into the inventory
POST :host/act/buy-attachment
:headers
item=Turbo+Core
# Equip a spare attachment on a tool (to), or unequip it. Counts from the tool's next stake.
POST :host/act/equip
:headers
item=Turbo+Core&to=2bd5f846-ce1d-45be-998e-26d2e97b7f68
POST :host/act/unequip
:headers
item=Turbo+Core&to=2bd5f846-ce1d-45be-998e-26d2e97b7f68
# Hire an advisor (config name) into a free seat
POST :host/act/hire
:headers
@@ -234,9 +215,8 @@ Content-Type: application/json
{"catalog": 0, "clientSeq": 2}
# Other sections' actions (clientSeq is echoed back): buy-tool (id), upgrade (tool),
# sell-tool (tool), buy-attachment (item), equip/unequip (item, to), sell-attachment (item),
# refine (recipe, qty or all), collect (job or none), buy-slot, upgrade-batch, hire (advisor), buy-seat,
# Other sections' actions (clientSeq is echoed back): buy-tool (id), upgrade (tool, track),
# sell-tool (tool), refine (recipe, qty or all), collect (job or none), buy-slot, upgrade-batch, hire (advisor), buy-seat,
# seat-advisor (advisor, seat), unseat-advisor (advisor), swap-advisors (advisor, with),
# dismiss-advisor (advisor), sell-moon (sell-moon)
POST :host/api/act/buy-tool
@@ -332,10 +312,10 @@ POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/resources
:headers
resource=Sollux&amount=500
# Admin: give a tool (store item id, tier 0..number of upgrades)
# Admin: give a tool (store item id, yield and cycle track levels from 0)
POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/tool
:headers
item=1&tier=2
item=1&yield=2&cycle=1
# Admin: finish all the player's running drills now (they can claim right away)
POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/finish-stakes
+426
View File
@@ -0,0 +1,426 @@
package main
// Drill upgrade tracks. Every store item has two named tracks of its own (config
// storeItems[].tracks): yield (the claim amount per cycle) and cycle (seconds per cycle).
// Each level of a track sets the value flat (not a multiplier) and has a price and,
// higher up, refined parts (refinery.go). A tool's reach is the item's and never changes.
// Levels belong to one tool (inventory_item.yield_level, cycle_level): copies upgrade
// separately, and copies with the same levels are interchangeable (pickTool).
import (
"context"
"errors"
"fmt"
"math"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// TrackLevel is one level of a track: the track's value from this level on (the claim
// amount, or the cycle in seconds), its price in credits and the refined items it uses up.
type TrackLevel struct {
Value float64 `json:"value"`
Price float64 `json:"price"`
Parts map[string]float64 `json:"parts,omitempty"`
}
// Track is one of a drill's upgrade tracks; level 0 is the item's own value.
type Track struct {
Name string `json:"name"`
Levels []TrackLevel `json:"levels"`
}
// Tracks are a drill's two upgrade tracks.
type Tracks struct {
Yield Track `json:"yield"`
Cycle Track `json:"cycle"`
}
// trackKinds are the tracks, in the order the views show them.
var trackKinds = []string{"yield", "cycle"}
// Levels are a tool's level on each track (0: the item's base values).
type Levels struct {
Yield, Cycle int
}
func (t Tool) Levels() Levels { return Levels{t.YieldLevel, t.CycleLevel} }
// Of is the level on the track of the kind.
func (lv Levels) Of(kind string) int {
if kind == "cycle" {
return lv.Cycle
}
return lv.Yield
}
// With is lv with the track of the kind at level n.
func (lv Levels) With(kind string, n int) Levels {
if kind == "cycle" {
lv.Cycle = n
} else {
lv.Yield = n
}
return lv
}
// Track is the item's track of the kind (yield or cycle), nil for another kind.
func (item *StoreItem) Track(kind string) *Track {
switch kind {
case "yield":
return &item.Tracks.Yield
case "cycle":
return &item.Tracks.Cycle
}
return nil
}
// TrackLevel is level n (1 on) of the track of the kind, nil past its top.
func (item *StoreItem) TrackLevel(kind string, n int) *TrackLevel {
if t := item.Track(kind); t != nil && n >= 1 && n <= len(t.Levels) {
return &t.Levels[n-1]
}
return nil
}
// trackValue is the track's value at level n: the base below level 1, the top past it.
func (t *Track) value(base float64, n int) float64 {
if n <= 0 || len(t.Levels) == 0 {
return base
}
return t.Levels[min(n, len(t.Levels))-1].Value
}
// ToolYield is what one completed stake returns at the levels, before bonuses.
func (item *StoreItem) ToolYield(lv Levels) float64 {
return item.Tracks.Yield.value(item.ClaimAmount, lv.Yield)
}
// ToolSecs is a stake's cycle at the levels, before bonuses.
func (item *StoreItem) ToolSecs(lv Levels) float64 {
return item.Tracks.Cycle.value(float64(item.CompletionTimeInSecs), lv.Cycle)
}
// ToolReach is how many wells a stake mines: the item's reach, at least 1.
func (item *StoreItem) ToolReach() int { return max(1, item.Reach) }
// MaxLevels is the top level of each track.
func (item *StoreItem) MaxLevels() Levels {
return Levels{len(item.Tracks.Yield.Levels), len(item.Tracks.Cycle.Levels)}
}
// trackParts is what level n of the item's track uses up, from the config.
func trackParts(storeItemID int, kind string, n int) map[string]float64 {
if item := configItem(storeItemID); item != nil {
if l := item.TrackLevel(kind, n); l != nil {
return l.Parts
}
}
return nil
}
// upgradesPrice is the credits paid for the levels: what a tool's upgrades cost.
func (item *StoreItem) upgradesPrice(lv Levels) float64 {
sum := 0.0
for _, kind := range trackKinds {
for n := 1; n <= lv.Of(kind); n++ {
if l := item.TrackLevel(kind, n); l != nil {
sum += l.Price
}
}
}
return sum
}
func (c *GameConfig) validateTracks(item StoreItem) error {
for _, kind := range trackKinds {
t := item.Track(kind)
if len(t.Levels) > 0 && t.Name == "" {
return fmt.Errorf("store item %d: the %s track needs a name", item.ID, kind)
}
prev := item.ToolYield(Levels{})
if kind == "cycle" {
prev = item.ToolSecs(Levels{})
}
for i, l := range t.Levels {
better := l.Value > prev
if kind == "cycle" {
better = l.Value < prev && l.Value >= 1
}
if !better || l.Price < 0 {
return fmt.Errorf("store item %d %s level %d: each level must improve on the one before (cycles at least 1 s), the price can't be negative",
item.ID, kind, i+1)
}
prev = l.Value
}
}
return nil
}
// ------------------------------------------------------------------ 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.
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 {
return err
}
var storeItemID int
var lv Levels
err = tx.QueryRow(ctx, "select store_item_id, yield_level, cycle_level from inventory_item where id = $1",
toolID).Scan(&storeItemID, &lv.Yield, &lv.Cycle)
if err != nil {
return err
}
item := configItem(storeItemID)
if item == nil {
return gameErr("That tool can't be upgraded")
}
n := lv.Of(kind) + 1
next := item.TrackLevel(kind, n)
if next == nil {
return gameErr("%s is already at its top level", item.Track(kind).Name)
}
if err := debit(ctx, tx, userID, next.Price); err != nil {
return err
}
if err := takeItems(ctx, tx, userID, next.Parts); err != nil {
return err
}
_, err = tx.Exec(ctx, "update inventory_item set "+kind+"_level = $2 where id = $1", toolID, n)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "insert into upgrade_event(inventory_item_id) values ($1)", toolID)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "upgrade",
map[string]any{"item": storeItemID, "track": kind, "level": n, "price": next.Price})
})
return toolID, err
}
// roundSecs rounds a cycle to a tenth of a second.
func roundSecs(s float64) float64 { return math.Round(s*10) / 10 }
// lockTool locks one of the player's tools.
func lockTool(ctx context.Context, tx pgx.Tx, userID, toolID string) error {
if !uuidPattern.MatchString(toolID) {
return gameErr("Tool not found")
}
var id string
err := tx.QueryRow(ctx, `
select id from inventory_item where id = $1 and user_id = $2 for update`, toolID, userID).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("Tool not found")
}
return err
}
// ------------------------------------------------------------------ view
// Tag is the levels as a short tag after a tool's name (" Y2 C1"), "" for a base tool.
func (lv Levels) Tag() string {
if lv.Yield == 0 && lv.Cycle == 0 {
return ""
}
return fmt.Sprintf(" Y%d C%d", lv.Yield, lv.Cycle)
}
// Title says the levels in words, for tooltips.
func (lv Levels) Title() string {
return fmt.Sprintf("Yield level %d, cycle level %d", lv.Yield, lv.Cycle)
}
// TrackRow is one of a tool's upgrade tracks (a ToolRow's), for the Equipment card and the
// drill panel. Values are a stake's now, with the player's bonuses and live events: the
// yield per cycle, or the cycle in seconds.
type TrackRow struct {
Kind string // yield, cycle
Name string // the track's name for this drill, e.g. "Twin rotors"
Level int
Max int
Value float64
Next *TrackLevel // nil at the top level
NextValue float64
CanAfford bool // credits and parts
Parts []PartRow // refined items the next level uses (refinery.go)
Missing string // why it can't be bought, if it can't
Short string // "Need ₥X more" when credits are short for it
RateFrom float64 // credits per hour (creditsPerHour) now and after the next level
RateTo float64
Cost float64 // the next level's price plus its parts at sell value
Ladder []TrackStep // every level, the other track as it is
}
// TrackStep is one level of a track, for the drill panel's ladder.
type TrackStep struct {
Level int
Value float64 // with the player's bonuses
Rate float64 // credits per hour at this level
Price float64 // 0 for level 0
Parts []PartRow
Current, Next bool
}
// Cycle is a cycle track.
func (r TrackRow) Cycle() bool { return r.Kind == "cycle" }
// RateGain is what the next level adds per hour.
func (r TrackRow) RateGain() float64 { return r.RateTo - r.RateFrom }
// Payback is how long the next level's extra income takes to repay its cost, "" if never.
func (r TrackRow) Payback() string {
if gain := r.RateGain(); gain > 0 {
return formatSpan(r.Cost / gain * 3600)
}
return ""
}
// Label is the button's text: "Twin rotors 2" (the level it buys).
func (r TrackRow) Label() string { return fmt.Sprintf("%s %d", r.Name, r.Level+1) }
// trackValue is a stake's yield or cycle (by kind) of the item at the levels, now.
func (s *State) trackValue(item *StoreItem, lv Levels, kind string) (value, rate float64) {
secs, yield := s.drillAt(item, lv, "")
rate = s.creditsPerHour(yield, secs, item.ToolReach())
if kind == "cycle" {
return secs, rate
}
return yield, rate
}
// trackRows are the tool's two tracks with their next levels and ladders.
func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
var rows []TrackRow
lv := t.Levels()
for _, kind := range trackKinds {
tr := item.Track(kind)
if len(tr.Levels) == 0 {
continue
}
r := TrackRow{Kind: kind, Name: tr.Name, Level: lv.Of(kind), Max: len(tr.Levels)}
r.Value, r.RateFrom = s.trackValue(item, lv, kind)
for n := 0; n <= r.Max; n++ {
st := TrackStep{Level: n, Current: n == r.Level, Next: n == r.Level+1}
st.Value, st.Rate = s.trackValue(item, lv.With(kind, n), kind)
if l := item.TrackLevel(kind, n); l != nil {
st.Price, st.Parts = l.Price, s.partRows(l.Parts)
}
r.Ladder = append(r.Ladder, st)
}
if next := item.TrackLevel(kind, r.Level+1); next != nil {
r.Next = next
r.NextValue, r.RateTo = s.trackValue(item, lv.With(kind, r.Level+1), kind)
r.Cost = next.Price + partsValue(cfg(), next.Parts)
r.Parts = s.partRows(next.Parts)
r.CanAfford = s.Balance >= next.Price
if !r.CanAfford {
r.Missing = shortBy(next.Price - s.Balance)
r.Short = r.Missing
}
for _, part := range r.Parts {
if !part.OK() && r.CanAfford {
r.CanAfford = false
r.Missing = fmt.Sprintf("Need %s %s (you have %s). Make it in the Refinery",
formatMoney(part.Need), part.Name, formatMoney(part.Have))
}
}
}
rows = append(rows, r)
}
return rows
}
// upgradedText names what a tool's latest upgrade on the track gave, for the toast.
func (s *State) upgradedText(t Tool, kind string) string {
item := s.StoreItem(t.StoreItemID)
lv := t.Levels()
if item == nil || item.Track(kind) == nil || lv.Of(kind) < 1 {
return "Tool upgraded"
}
before, _ := s.trackValue(item, lv.With(kind, lv.Of(kind)-1), kind)
now, _ := s.trackValue(item, lv, kind)
name := fmt.Sprintf("%s %d", item.Track(kind).Name, lv.Of(kind))
if kind == "cycle" {
return fmt.Sprintf("%s: a cycle takes %s (was %s)", name, formatCycle(now), formatCycle(before))
}
return fmt.Sprintf("%s: +%s per cycle (was +%s)", name, formatMoney(now), formatMoney(before))
}
// DrillPanel is the side panel for a drill selected in the Equipment section: its stats,
// both upgrade tracks with their ladders, deploying an idle copy and selling it.
type DrillPanel struct {
ToolRow
Mining bool
Resale float64 // what it sells for (resale.go)
NoSale string // why it can't be sold, if it can't
// While it mines: the idle tools of its type, the best one and its best well.
IdleSame int
IdleBest *Tool
DeployTo *Suggestion
IdleURL string // opens IdleBest's panel
// Store: the Store is unlocked, so the panel offers upgrades and selling (actions gated
// on "store", onboarding.go). Before that it shows stats and the ladders only.
Store bool
}
// noSale is why a tool can't be sold (only-tool, claim-first, mining), "" if it can.
func (s *State) noSale(row ToolRow) string {
switch {
case len(s.Tools) <= 1:
return "only-tool"
case row.Stake != nil && row.Stake.Ready:
return "claim-first"
case row.Stake != nil:
return "mining"
}
return ""
}
// SellConfirm is the question before selling the drill (its Sell button's confirm dialog).
func (d *DrillPanel) SellConfirm() string {
return fmt.Sprintf("Sell this %s%s for %s?", d.Item.Name, d.Levels().Tag(), formatCredits(d.Resale))
}
var noSaleText = map[string]string{
"only-tool": "You can't sell your only tool",
"claim-first": "Claim its haul first",
"mining": "Still mining. Sell it once it's idle",
}
func (s *State) drillPanel(row ToolRow) *DrillPanel {
d := &DrillPanel{ToolRow: row, Mining: row.Stake != nil, Resale: row.Item.ToolResale(row.Levels()),
Store: s.Unlocked("store")}
d.NoSale = noSaleText[s.noSale(row)]
if d.Mining {
// The most levels: the one pickTool would deploy.
for i := range s.Tools {
t := &s.Tools[i]
if t.StoreItemID != row.StoreItemID || s.StakeForTool(t.ID) != nil {
continue
}
d.IdleSame++
if b := d.IdleBest; b == nil || t.YieldLevel+t.CycleLevel > b.YieldLevel+b.CycleLevel ||
(t.YieldLevel+t.CycleLevel == b.YieldLevel+b.CycleLevel && t.YieldLevel > b.YieldLevel) {
d.IdleBest = t
}
}
if d.IdleBest != nil {
d.IdleURL = View{Section: "equipment", Drill: d.IdleBest.ID}.URL()
if m := s.buySuggestions(d.IdleBest.ID); m != nil && len(m.Suggestions) > 0 {
d.DeployTo = &m.Suggestions[0]
}
}
}
return d
}
+56 -159
View File
@@ -38,7 +38,14 @@ var templateFuncs = template.FuncMap{
// short is the tooltip on a button the player can't afford.
"short": func(balance, price float64) string { return shortBy(price - balance) },
"mins": func(m int) string { return formatDuration(time.Duration(m) * time.Minute) },
"secs": formatCycle,
"secs": secsText,
// A track's value (tracks.go): a cycle ("2.6s") or a yield per cycle ("+5").
"trackval": func(cycle bool, v float64) string {
if cycle {
return formatCycle(v)
}
return "+" + formatMoney(v)
},
"curk": formatCompact,
"wells": wellsText,
"dur": formatDuration,
@@ -125,7 +132,11 @@ func formatDuration(d time.Duration) string {
}
// formatCycle formats a drill cycle in seconds: "3s", "45s", "2m", "2m 30s".
func formatCycle(secs int) string {
func formatCycle(f float64) string {
if f < 60 && f != math.Trunc(f) {
return strconv.FormatFloat(roundSecs(f), 'f', -1, 64) + "s"
}
secs := int(math.Round(f))
if secs < 60 {
return fmt.Sprintf("%ds", secs)
}
@@ -206,8 +217,8 @@ func (v View) resolve(s *State) View {
}
}
}
// A tool's panel opens with Equipment; what in it belongs to the Store (upgrade,
// attachments, selling) shows once the Store is unlocked (DrillPanel.Store).
// A tool's panel opens with Equipment; what in it belongs to the Store (upgrades,
// selling) shows once the Store is unlocked (DrillPanel.Store).
if v.Section != "equipment" || s.tool(v.Drill) == nil || !s.Unlocked("equipment") {
v.Drill = ""
}
@@ -318,7 +329,6 @@ type Page struct {
Trade *MarketPage // market section: quotes, chart, portfolio (market.go)
MaxSupply float64 // a full supply bar: the selected moon's grade (else the default grade)
Refinery *RefineryPage // refinery section (refinery.go)
Gear []AttachmentCard // store section (attachments.go)
Drill *DrillPanel // equipment section, with a tool selected
Advisors *AdvisorsPage // advisors section (advisors.go)
Ranking *LeaderboardPage // leaderboard section (leaderboard.go), loaded by the server
@@ -490,8 +500,7 @@ type WellDetail struct {
type StakeRow struct {
Stake
Item *StoreItem
Tier int
Gear []*Attachment
Levels Levels // the tool's track levels
Ready bool
Remaining time.Duration
Take float64 // what claiming it would get now, from what its wells hold
@@ -505,14 +514,13 @@ type StakeRow struct {
// ReachText says how many wells it mines ("" for one).
func (r StakeRow) ReachText() string { return reachLabel(r.Reach) }
// ToolGroup is tools of the same type, tier and attachments. Secs and Yield are what a
// stake would take and return now, with the player's bonuses and live events.
// ToolGroup is tools of the same type and track levels. Secs and Yield are what a stake
// would take and return now, with the player's bonuses and live events.
type ToolGroup struct {
Tool
Item *StoreItem
Gear []*Attachment
Count int
Secs int
Secs float64
Yield float64
Reach int // wells of a moon one cycle mines
Here float64 // what a cycle gets from the selected well's moon after its current drills (the well panel)
@@ -550,18 +558,10 @@ func reachLabel(reach int) string {
type ToolRow struct {
ToolGroup
FreeSlots int // attachment slots left
Selected bool
Idle int // copies (Count) not mining
MaxTier int
Next *Upgrade
CanAfford bool
Stake *StakeRow
Parts []PartRow // refined items the next upgrade uses (refinery.go)
Missing string // why the upgrade can't be bought, if it can't
Short string // "Need ₥X more" when credits are short for it
Preview *UpgradePreview
Ladder []TierRow // every tier, with the player's bonuses
Selected bool
Idle int // copies (Count) not mining
Stake *StakeRow
Tracks []TrackRow // its yield and cycle upgrade tracks (tracks.go)
}
type StoreRow struct {
@@ -576,7 +576,7 @@ type StoreRow struct {
func (r StoreRow) RateGain() float64 { return r.Rate - r.BestRate }
// ReachText says how many wells the tool mines ("" for one).
func (r StoreRow) ReachText() string { return reachLabel(r.Item.ToolReach(0)) }
func (r StoreRow) ReachText() string { return reachLabel(r.Item.ToolReach()) }
type MarketRow struct {
ResourceBalance
@@ -640,7 +640,7 @@ func (s *State) stakeRow(st Stake) StakeRow {
row := StakeRow{Stake: st, Remaining: st.Remaining(s.Now), Take: st.Amount}
row.Ready = row.Remaining == 0
if t := s.tool(st.ToolID); t != nil {
row.Item, row.Tier, row.Gear = s.StoreItem(t.StoreItemID), t.Tier, s.gear(t.ID)
row.Item, row.Levels = s.StoreItem(t.StoreItemID), t.Levels()
}
if m := s.moon(st.MoonID); m != nil {
supply := m.supply()
@@ -738,12 +738,12 @@ func (s *State) wideGroup(m *Moon) *DrillGroup {
return g
}
// toolGroups groups tools by type, tier and attachments (and with all, whether they're
// toolGroups groups tools by type and track levels (and with all, whether they're
// mining), so duplicates show as one row. Yield is for a well of the resource ("" for any).
func (s *State) toolGroups(idleOnly bool, resource string) []ToolGroup {
var groups []ToolGroup
key := func(t Tool) string {
return fmt.Sprint(t.StoreItemID, t.Tier, s.Fitted[t.ID], s.StakeForTool(t.ID) != nil)
return fmt.Sprint(t.StoreItemID, t.Levels(), s.StakeForTool(t.ID) != nil)
}
keys := map[string]int{}
for _, t := range s.Tools {
@@ -759,7 +759,7 @@ func (s *State) toolGroups(idleOnly bool, resource string) []ToolGroup {
continue
}
keys[key(t)] = len(groups)
g := ToolGroup{Tool: t, Item: item, Gear: s.gear(t.ID), Count: 1, Reach: item.ToolReach(t.Tier)}
g := ToolGroup{Tool: t, Item: item, Count: 1, Reach: item.ToolReach()}
g.Secs, g.Yield = s.drill(t, item, resource)
groups = append(groups, g)
}
@@ -881,30 +881,11 @@ func (s *State) page(v View) *Page {
continue
}
row := ToolRow{
ToolGroup: ToolGroup{Tool: t, Item: item, Gear: s.gear(t.ID), Count: 1, Reach: item.ToolReach(t.Tier)},
ToolGroup: ToolGroup{Tool: t, Item: item, Count: 1, Reach: item.ToolReach()},
Selected: t.ID == v.Drill,
MaxTier: len(item.Upgrades),
Next: item.NextUpgrade(t.Tier),
}
row.Secs, row.Yield = s.drill(t, item, "")
row.FreeSlots = max(0, cfg().Attachments.SlotsPerTool-len(row.Gear))
row.Ladder = s.tierLadder(t, item)
if row.Next != nil {
row.Preview = s.upgradePreview(t, item)
row.CanAfford = s.Balance >= row.Next.Price
if !row.CanAfford {
row.Missing = shortBy(row.Next.Price - s.Balance)
row.Short = row.Missing
}
row.Parts = s.upgradeParts(t.StoreItemID, row.Next.Tier)
for _, part := range row.Parts {
if !part.OK() && row.CanAfford {
row.CanAfford = false
row.Missing = fmt.Sprintf("Need %s %s (you have %s). Make it in the Refinery",
formatMoney(part.Need), part.Name, formatMoney(part.Have))
}
}
}
row.Tracks = s.trackRows(t, item)
if st := s.StakeForTool(t.ID); st != nil {
sr := s.stakeRow(*st)
row.Stake = &sr
@@ -926,14 +907,12 @@ func (s *State) page(v View) *Page {
}
case "store":
p.Gear = s.attachmentCards()
best := s.bestToolRate()
for i := range s.Config.StoreItems {
item := &s.Config.StoreItems[i]
row := StoreRow{Item: item, CanAfford: s.Balance >= item.Price, BestRate: best}
secs, yield := drillStats(item.CompletionTimeInSecs, item.ToolYield(0), s.Bonus(),
s.CycleBoosts.For("all"), s.YieldBoosts.For(""))
row.Rate = s.creditsPerHour(yield, secs, item.ToolReach(0))
secs, yield := s.drillAt(item, Levels{}, "")
row.Rate = s.creditsPerHour(yield, secs, item.ToolReach())
for _, t := range s.Tools {
if t.StoreItemID == item.ID {
row.Owned++
@@ -970,22 +949,9 @@ func (s *State) page(v View) *Page {
// Busy is how many of the row's copies are mining.
func (r ToolRow) Busy() int { return r.Count - r.Idle }
// Pips is one entry per attachment slot, true where one is equipped.
func (r ToolRow) Pips() []bool {
pips := make([]bool, len(r.Gear)+r.FreeSlots)
for i := range r.Gear {
pips[i] = true
}
return pips
}
// copyKey is the same for copies of a tool: same type, tier and attachments.
// copyKey is the same for copies of a tool: same type and track levels.
func (s *State) copyKey(t Tool) string {
var names []string
for _, g := range s.gear(t.ID) {
names = append(names, g.Name)
}
return fmt.Sprint(t.StoreItemID, t.Tier, names)
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
@@ -1059,7 +1025,7 @@ func (p *Page) StakeURL(st *StakeRow) string {
type BuyModal struct {
ToolID string
Item *StoreItem
Secs int
Secs float64
Yield float64
Reach int
Suggestions []Suggestion
@@ -1087,9 +1053,9 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
if item == nil {
return nil
}
m := &BuyModal{ToolID: toolID, Item: item, NoMoons: len(s.Moons) == 0, Reach: item.ToolReach(t.Tier)}
m := &BuyModal{ToolID: toolID, Item: item, NoMoons: len(s.Moons) == 0, Reach: item.ToolReach()}
m.Secs, m.Yield = s.drill(*t, item, "")
cycle := float64(m.Secs) / 60
cycle := m.Secs / 60
var all []Suggestion
for i := range s.Moons {
moon := &s.Moons[i]
@@ -1149,93 +1115,13 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
// ReachText says how many wells the tool mines ("" for one).
func (m *BuyModal) ReachText() string { return reachLabel(m.Reach) }
// ------------------------------------------------------------------ upgrade previews
// UpgradePreview is what a tool's next tier changes, with the player's bonuses now.
type UpgradePreview struct {
Tier int
YieldFrom, YieldTo float64
SecsFrom, SecsTo int
ReachFrom, ReachTo int
RateFrom, RateTo float64 // credits per hour (creditsPerHour)
Cost float64 // price plus the parts at sell value
}
func (u *UpgradePreview) RateGain() float64 { return u.RateTo - u.RateFrom }
// Payback is how long the extra income takes to repay the cost, "" if it never does.
func (u *UpgradePreview) Payback() string {
if gain := u.RateGain(); gain > 0 {
return formatSpan(u.Cost / gain * 3600)
}
return ""
}
// TierRow is one tier of a tool, for the drill panel's ladder.
type TierRow struct {
Tier, Secs, Reach int
Yield, Rate float64
Current, Next bool
}
// tierStats is what a stake of the tool would take and return at the given tier, with its
// attachments, the player's advisors and live events (s.drill at its own tier).
func (s *State) tierStats(t Tool, item *StoreItem, tier int) (int, float64, int) {
secs, yield := drillStats(item.ToolSecs(tier), item.ToolYield(tier), s.toolBonus(t.ID),
s.CycleBoosts.For("all"), s.YieldBoosts.For(""))
return secs, yield, item.ToolReach(tier)
}
func (s *State) upgradePreview(t Tool, item *StoreItem) *UpgradePreview {
next := item.NextUpgrade(t.Tier)
if next == nil {
return nil
}
u := &UpgradePreview{Tier: next.Tier, Cost: next.Price + partsValue(cfg(), upgradeParts(item.ID, next.Tier))}
u.SecsFrom, u.YieldFrom, u.ReachFrom = s.tierStats(t, item, t.Tier)
u.SecsTo, u.YieldTo, u.ReachTo = s.tierStats(t, item, next.Tier)
u.RateFrom = s.creditsPerHour(u.YieldFrom, u.SecsFrom, u.ReachFrom)
u.RateTo = s.creditsPerHour(u.YieldTo, u.SecsTo, u.ReachTo)
return u
}
func (s *State) tierLadder(t Tool, item *StoreItem) []TierRow {
var rows []TierRow
for tier := 0; tier <= len(item.Upgrades); tier++ {
r := TierRow{Tier: tier, Current: tier == t.Tier, Next: tier == t.Tier+1}
r.Secs, r.Yield, r.Reach = s.tierStats(t, item, tier)
r.Rate = s.creditsPerHour(r.Yield, r.Secs, r.Reach)
rows = append(rows, r)
}
return rows
}
// upgradedText names what a tool's latest upgrade gave, for the toast.
func (s *State) upgradedText(t Tool) string {
item := s.StoreItem(t.StoreItemID)
if item == nil || t.Tier < 1 {
return "Tool upgraded"
}
_, before, reachFrom := s.tierStats(t, item, t.Tier-1)
_, yield, reach := s.tierStats(t, item, t.Tier)
msg := fmt.Sprintf("Upgraded to T%d: +%s per cycle", t.Tier, formatMoney(yield))
if yield > before {
msg += " (was +" + formatMoney(before) + ")"
}
if reach > reachFrom {
label := reachLabel(reach)
msg += ", now " + strings.ToLower(label[:1]) + label[1:]
}
return msg
}
// bestToolRate is what the player's best-earning tool makes per hour (creditsPerHour).
func (s *State) bestToolRate() float64 {
best := 0.0
for _, t := range s.Tools {
if item := s.StoreItem(t.StoreItemID); item != nil {
secs, yield, reach := s.tierStats(t, item, t.Tier)
best = max(best, s.creditsPerHour(yield, secs, reach))
secs, yield := s.drill(t, item, "")
best = max(best, s.creditsPerHour(yield, secs, item.ToolReach()))
}
}
return best
@@ -1245,7 +1131,7 @@ func (s *State) bestToolRate() float64 {
// default grade (State.MoonGrade), deployed on its
// richest well (what its reach can hold, as the economy page's sim counts it), sold at
// the average resource price now (market, live events, advisors).
func (s *State) creditsPerHour(yield float64, secs, reach int) float64 {
func (s *State) creditsPerHour(yield, secs float64, reach int) float64 {
c := cfg()
if len(c.ResourcePrices) == 0 {
return 0
@@ -1295,7 +1181,7 @@ func formatCompact(n float64) string {
func formatSpan(secs float64) string {
n := int(math.Ceil(secs))
if n < 3600 {
return formatCycle(n)
return formatCycle(float64(n))
}
h, m := n/3600, n%3600/60
if m == 0 {
@@ -1498,7 +1384,7 @@ func (r StakeRow) Pct() float64 {
if r.Ready || r.DurationInSecs <= 0 {
return 100
}
return min(100, max(0, 100*(1-r.Remaining.Seconds()/float64(r.DurationInSecs))))
return min(100, max(0, 100*(1-r.Remaining.Seconds()/r.DurationInSecs)))
}
// Code is the tool's model code, the last word of its name ("PD-1").
@@ -1524,7 +1410,7 @@ func (r StakeRow) Fate() string {
if r.Item != nil {
name = r.Item.Name
}
s := fmt.Sprintf("%s T%d", name, r.Tier)
s := name + r.Levels.Tag()
if r.Reach > 1 {
s += " (" + reachLabel(r.Reach) + ")"
}
@@ -1548,7 +1434,7 @@ func (r StakeRow) Fate() string {
func (r WellRow) DrillsTitle() string { return drillsTitle(r.Queue()) }
func (g DrillGroup) DrillsTitle() string { return drillsTitle(g.Drills) }
// DrillLabel names a row's own drills compactly ("PD-1 T1", "PD-1 ×3", "PD-1 ×3 +1");
// DrillLabel names a row's own drills compactly ("PD-1 Y1 C2", "PD-1 ×3", "PD-1 ×3 +1");
// a well's multi-well drills show only as diamond-capped bars.
func (r WellRow) DrillLabel() string { return drillLabel(r.Drills) }
func (g DrillGroup) DrillLabel() string { return drillLabel(g.Drills) }
@@ -1566,7 +1452,7 @@ func drillLabel(q []StakeRow) string {
return ""
}
if len(q) == 1 {
return fmt.Sprintf("%s T%d", q[0].Code(), q[0].Tier)
return q[0].Code() + q[0].Levels.Tag()
}
count := map[string]int{}
top := q[0].Code()
@@ -1647,3 +1533,14 @@ type SupplyBar struct {
func (r WellRow) Bar(max float64, now time.Time, panel bool) SupplyBar {
return SupplyBar{W: r, Max: max, Now: now, Panel: panel}
}
// secsText is formatCycle for a template value, int or float64 seconds.
func secsText(v any) string {
switch n := v.(type) {
case int:
return formatCycle(float64(n))
case float64:
return formatCycle(n)
}
return fmt.Sprint(v)
}
+5 -5
View File
@@ -6,8 +6,8 @@ import (
"testing"
)
// A tool's panel opens as soon as Equipment is unlocked; its Store parts (upgrade,
// attachments, selling) show only once the Store is.
// A tool's panel opens as soon as Equipment is unlocked; its Store parts (upgrades,
// selling) show only once the Store is.
func TestViewDrillPanelOnboarding(t *testing.T) {
tmpl, err := (&server{}).parseTemplates()
if err != nil {
@@ -44,10 +44,10 @@ func TestViewDrillPanelOnboarding(t *testing.T) {
t.Fatalf("render: %v", err)
}
html := b.String()
if !strings.Contains(html, "<h3>Tiers</h3>") {
t.Errorf("%v: no tier ladder", c.flags)
if strings.Count(html, "track-ladder") != 2 {
t.Errorf("%v: no track ladders", c.flags)
}
for _, part := range []string{"/act/sell-tool", "<h3>Slots</h3>", "Spare attachments"} {
for _, part := range []string{"/act/sell-tool", "/act/upgrade"} {
if strings.Contains(html, part) != c.store {
t.Errorf("%v: %q shown %v, want %v", c.flags, part, !c.store, c.store)
}
+10 -2
View File
@@ -32,6 +32,7 @@ export function left(ms) {
// Cycles: 3s, 2m, 2m 30s.
export function secs(n) {
if (n < 60 && n !== Math.trunc(n)) return `${Math.round(n * 10) / 10}s`; // tenths, as formatCycle
n = Math.round(n);
if (n < 60) return `${n}s`;
return n % 60 === 0 ? `${n / 60}m` : `${Math.floor(n / 60)}m ${n % 60}s`;
@@ -52,11 +53,18 @@ export function hue(id) {
// The tool's model code, the last word of its name ("PD-1").
export const code = (name) => name?.trim().split(/\s+/).pop() || "Tool";
// A row's drills, compactly ("PD-1 T1", "PD-1 ×3", "PD-1 ×3 +1"), like drillLabel in views.go.
// A row's drills, compactly ("PD-1 Y1 C2", "PD-1 ×3", "PD-1 ×3 +1"), like drillLabel in views.go.
// A tool's track levels after its name (" Y2 C1"), "" for a base tool: Levels.Tag in tracks.go.
export function levelsTag(t) {
const y = t?.yieldLevel ?? 0;
const c = t?.cycleLevel ?? 0;
return y || c ? ` Y${y} C${c}` : "";
}
export function drillLabel(drills, toolOf) {
if (!drills.length) return "";
const codeOf = (d) => toolOf(d.toolId)?.code ?? "Tool";
if (drills.length === 1) return `${codeOf(drills[0])} T${toolOf(drills[0].toolId)?.tier ?? 0}`;
if (drills.length === 1) return codeOf(drills[0]) + levelsTag(toolOf(drills[0].toolId));
const count = new Map();
let top = codeOf(drills[0]);
for (const d of drills) {
+6 -5
View File
@@ -70,16 +70,17 @@ function addItem(st, name, delta) {
// ---------------------------------------------------------------- tools and stakes
// The tool to deploy for id: it, or an idle copy of it (same store item and tier).
// The tool to deploy for id: it, or an idle copy of it (same store item and track levels).
function idleCopy(st, id) {
const t = find(st.tools, id);
if (!t || !t.stakeId) return t;
return st.tools.find((x) => !x.stakeId && x.storeItemId === t.storeItemId && x.tier === t.tier);
return st.tools.find((x) => !x.stakeId && x.storeItemId === t.storeItemId && x.yieldLevel === t.yieldLevel && x.cycleLevel === t.cycleLevel);
}
const yieldBoost = (st, resource) => st.rules?.boosts?.yield?.[resource] ?? 1;
const sameGroup = (g, tool) => g.storeItemId === tool.storeItemId && g.tier === tool.tier;
const sameGroup = (g, tool) =>
g.storeItemId === tool.storeItemId && g.yieldLevel === tool.yieldLevel && g.cycleLevel === tool.cycleLevel;
// A new stake of tool on well starting at ctx.now. group: the well panel's idle group for
// it (cycle and yield already for this well).
@@ -153,7 +154,8 @@ function removeStake(st, s) {
toolId: tool.id,
storeItemId: tool.storeItemId,
name: tool.name,
tier: tool.tier,
yieldLevel: tool.yieldLevel,
cycleLevel: tool.cycleLevel,
count: 1,
cycleSecs: tool.cycleSecs,
yield: y,
@@ -161,7 +163,6 @@ function removeStake(st, s) {
here: Math.min(y, free),
free,
short: free < y,
attachments: tool.attachments ?? [],
});
}
}
+3 -3
View File
@@ -319,7 +319,7 @@ function predictWell(st, w) {
const groups = new Map();
for (const t of st.tools) {
if (t.stakeId) continue;
const key = `${t.storeItemId}:${t.tier}:${(t.attachments ?? []).join("+")}`;
const key = `${t.storeItemId}:${t.yieldLevel}:${t.cycleLevel}`;
const g = groups.get(key);
if (g) {
g.count++;
@@ -330,7 +330,8 @@ function predictWell(st, w) {
toolId: t.id,
storeItemId: t.storeItemId,
name: t.name,
tier: t.tier,
yieldLevel: t.yieldLevel,
cycleLevel: t.cycleLevel,
count: 1,
cycleSecs: t.cycleSecs,
yield: y,
@@ -338,7 +339,6 @@ function predictWell(st, w) {
here: Math.min(y, free),
free,
short: free < y,
attachments: t.attachments ?? [],
});
}
const quote = st.market && P.find(st.market.quotes, w.resource, "item");
+8 -13
View File
@@ -7,7 +7,7 @@ import { Countdown, Dot, Num, SupplyBar } from "./ui.jsx";
import { ReadyButton } from "./buttons.jsx";
import { ViewLink, WideClaim, wideOn } from "./moons.jsx";
import { colorOf, imageOf, toolOf } from "./catalog.js";
import { money, cur, secs } from "./format.js";
import { money, cur, secs, levelsTag } from "./format.js";
import { useFlip } from "./flip.js";
import { now } from "./clock.js";
import { isReady, find } from "./predict.js";
@@ -88,7 +88,7 @@ function WellPanel({ st, moon, well, w }) {
{well.idle.length ? (
<div class="deploy-cards" ref={cards}>
{well.idle.map((g) => (
<DeployCard key={`${g.storeItemId}:${g.tier}:${(g.attachments ?? []).join("+")}`} st={st} g={g} wellId={well.id} />
<DeployCard key={`${g.storeItemId}:${g.yieldLevel}:${g.cycleLevel}`} st={st} g={g} wellId={well.id} />
))}
</div>
) : (
@@ -122,7 +122,8 @@ function WellPanel({ st, moon, well, w }) {
<span class="drill-thumb">{tool && <img src={tool.image} alt="" />}</span>
<span class="drill-main">
<span class="drill-name">
{tool?.name ?? "Tool"} <span class="muted">T{tool?.tier ?? 0}</span>
{tool?.name ?? "Tool"}
{levelsTag(tool) && <span class="muted">{levelsTag(tool)}</span>}
</span>
<span class="drill-meta small">
<span class="muted">
@@ -192,30 +193,24 @@ function WellPanel({ st, moon, well, w }) {
}
function DeployCard({ st, g, wellId }) {
const key = `${g.storeItemId}:${g.tier}:${(g.attachments ?? []).join("+")}`;
const key = `${g.storeItemId}:${g.yieldLevel}:${g.cycleLevel}`;
const cls = g.short ? (g.here > 0 ? " is-short" : " is-spoken") : "";
return (
<button
type="button"
data-key={key}
class={`btn deploy-card${cls}`}
title={`Deploy ${g.name} T${g.tier}`}
title={`Deploy ${g.name}${levelsTag(g)}`}
onClick={(e) => act("stake", { tool: g.toolId, target: wellId }, e.currentTarget, `stake:${key}`)}
>
<span class="deploy-card-media">
<img src={imageOf(st, g.storeItemId)} alt="" />
{g.attachments?.length > 0 && (
<span class="deploy-card-pips pips">
{g.attachments.map((a, i) => (
<i key={i} class="pip is-on" />
))}
</span>
)}
{g.count > 1 && <span class="deploy-card-count num">×{g.count}</span>}
</span>
<span class="deploy-card-info">
<span class="deploy-card-name">
<span class="deploy-card-title">{g.name}</span> <span class="tier-chip num">T{g.tier}</span>
<span class="deploy-card-title">{g.name}</span>
{levelsTag(g) && <span class="tier-chip num">{levelsTag(g)}</span>}
</span>
<span class="deploy-card-meta">
<span class="num">
+74 -68
View File
@@ -1054,28 +1054,11 @@ a.htmx-request::after {
margin-top: 6px;
}
/* Drill attachments (store, tool names, drill panel) and advisors. */
.mod-tag {
margin-left: 6px;
padding: 1px 6px;
border: 1px solid rgb(var(--violet-rgb) / 0.45);
color: var(--violet-hi);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
white-space: nowrap;
}
.mod-slot {
margin-left: 6px;
color: var(--faint);
font-size: 11px;
white-space: nowrap;
}
/* Advisors' effects. */
.mod-effect {
color: var(--violet-hi);
font-size: 14px;
}
.gear-card .card-stats,
.advisor-card .card-stats {
grid-template-columns: repeat(2, 1fr);
}
@@ -1086,42 +1069,79 @@ a.htmx-request::after {
.tool-status {
min-height: 1.5em;
}
/* Attachment slots and equipped attachments sit on the image's top-left corner. */
.tool-card {
position: relative;
/* A drill's two upgrade tracks (tracks.go), side by side: name and level, value ▸ next,
the button and a need line. Every box has the same rows in every state, so the buttons
keep their place (clicker rule). */
.track-ups {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.tool-gear {
position: absolute;
top: 8px;
left: 8px;
.track-up {
display: flex;
flex-direction: column;
min-width: 0;
}
.track-up-head {
display: flex;
align-items: baseline;
gap: 6px;
color: var(--text-2);
}
.track-up-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-up-head .num {
margin-left: auto;
}
.track-up-val {
min-height: 1.5em;
white-space: nowrap;
}
.track-up .card-buy {
margin-top: 4px;
}
.track-up .card-buy.is-max {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 0;
max-width: calc(100% - 16px);
padding: 4px 6px;
background: rgb(var(--card-2-rgb) / 0.82);
pointer-events: none;
justify-content: center;
color: var(--muted);
border-color: var(--line);
cursor: default;
}
.track-up .up-need {
min-height: 1.6em;
margin-top: 2px;
}
.track-up .up-need .upgrade-parts {
margin-top: 0;
}
.track-up .up-short {
display: block;
margin-top: 0;
}
/* Tool, store and attachment cards: the render spans the card edge to edge (cropped to
/* Tool and store cards: the render spans the card edge to edge (cropped to
its middle) and the info under it stays compact. */
:is(.tool-card, .store-card, .gear-card) .card-media {
:is(.tool-card, .store-card) .card-media {
height: auto;
aspect-ratio: 4 / 3;
overflow: hidden;
}
:is(.tool-card, .store-card, .gear-card) .card-media img {
:is(.tool-card, .store-card) .card-media img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 30%; /* keep the tops of the tall rigs */
}
:is(.tool-card, .store-card, .gear-card) .card-body {
:is(.tool-card, .store-card) .card-body {
gap: 4px;
padding: 12px 14px 14px;
}
:is(.tool-card, .store-card, .gear-card) h2 {
:is(.tool-card, .store-card) h2 {
font-size: 15px;
letter-spacing: 0.1em;
}
@@ -1130,25 +1150,25 @@ a.htmx-request::after {
align-items: baseline;
gap: 0 6px;
}
:is(.tool-card, .store-card, .gear-card) .card-desc {
:is(.tool-card, .store-card) .card-desc {
font-size: 13px;
}
:is(.tool-card, .store-card, .gear-card) .card-stats {
:is(.tool-card, .store-card) .card-stats {
display: flex;
flex-wrap: wrap;
gap: 2px 16px;
padding-top: 8px;
}
:is(.tool-card, .store-card, .gear-card) .card-stats > div {
:is(.tool-card, .store-card) .card-stats > div {
display: flex;
align-items: baseline;
gap: 6px;
}
:is(.tool-card, .store-card, .gear-card) .card-stats dd {
:is(.tool-card, .store-card) .card-stats dd {
margin-top: 0;
font-size: 14px;
}
:is(.tool-card, .store-card, .gear-card) .btn.card-buy {
:is(.tool-card, .store-card) .btn.card-buy {
height: 34px;
margin-top: 6px;
}
@@ -1269,18 +1289,13 @@ a.htmx-request::after {
.tier-ladder .is-next .tl-tier::after {
content: " ▸";
}
.pips {
display: inline-flex;
gap: 4px;
/* A tool's track levels after its name (" Y2 C1"). */
.lv-tag {
margin-left: 0.4em;
white-space: nowrap;
}
.pip {
width: 9px;
height: 9px;
border: 1px solid var(--violet);
transform: rotate(45deg);
}
.pip.is-on {
background: var(--violet);
.track-ladder li {
grid-template-columns: 3em 1fr 1.6fr 3.6em;
}
.advisor-card.is-hired {
border-color: rgb(var(--ready-rgb) / 0.4);
@@ -1410,30 +1425,30 @@ a.adv-seat-in[aria-current] {
letter-spacing: 0.08em;
}
:is(.tool-card, .store-card, .gear-card) .card-media {
:is(.tool-card, .store-card) .card-media {
width: 112px;
aspect-ratio: auto;
}
:is(.tool-card, .store-card, .gear-card) h2 {
:is(.tool-card, .store-card) h2 {
font-size: 14px;
letter-spacing: 0.06em;
}
:is(.tool-card, .store-card, .gear-card) .card-media img {
:is(.tool-card, .store-card) .card-media img {
height: 100%;
}
:is(.tool-card, .store-card, .gear-card) .card-body {
:is(.tool-card, .store-card) .card-body {
padding: 10px 12px 12px;
}
:is(.tool-card, .store-card, .gear-card) .card-stats {
:is(.tool-card, .store-card) .card-stats {
justify-content: flex-start;
gap: 2px 12px;
}
:is(.tool-card, .store-card, .gear-card) .btn.card-buy {
:is(.tool-card, .store-card) .btn.card-buy {
height: auto;
min-height: 30px;
padding: 4px 8px;
@@ -1441,9 +1456,6 @@ a.adv-seat-in[aria-current] {
letter-spacing: 0.04em;
}
.tool-gear {
max-width: 96px;
}
}
.steps {
@@ -1583,7 +1595,7 @@ a.adv-seat-in[aria-current] {
}
/* Idle tools in the well panel: one click deploys. The render fills the card (3:2, a bit
flatter than the equipment cards so two rows fit) with the count and attachments on it; one line of name, one of
flatter than the equipment cards so two rows fit) with the count on it; one line of name, one of
stats. Two columns in the side panel, one on narrow screens (image on the left). */
.deploy-cards {
display: grid;
@@ -1626,7 +1638,6 @@ a.adv-seat-in[aria-current] {
object-position: 50% 30%; /* keep the tops of the tall rigs */
}
.deploy-card-pips,
.deploy-card-count {
position: absolute;
top: 6px;
@@ -1634,11 +1645,6 @@ a.adv-seat-in[aria-current] {
background: rgb(var(--card-2-rgb) / 0.82);
}
.deploy-card-pips {
left: 6px;
align-items: center;
padding: 5px 6px;
}
.deploy-card-count {
right: 6px;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

+55 -54
View File
@@ -41,7 +41,6 @@ function init() {
for (const r of doc.storeItems ?? []) origin.set(r, r.id);
for (const r of doc.refinery?.items ?? []) origin.set(r, r.name);
for (const r of doc.moons ?? []) origin.set(r, r.name);
for (const r of doc.attachments?.items ?? []) origin.set(r, r.name);
for (const r of doc.advisors?.items ?? []) origin.set(r, r.name);
}
adopt(draft);
@@ -82,7 +81,6 @@ function init() {
};
const resourceNames = () => Object.keys(draft.resourcePrices ?? {}).sort();
const refinedNames = () => (draft.refinery?.items ?? []).map((i) => i.name);
const attachmentNames = () => (draft.attachments?.items ?? []).map((i) => i.name);
const advisorNames = () => (draft.advisors?.items ?? []).map((i) => i.name);
const quote = (name) => (data.market ?? []).find((q) => q.name === name);
const spark = (q) =>
@@ -229,6 +227,22 @@ function init() {
{ k: "completionTimeInSecs", label: "Cycle s", type: "int", min: 1 },
// Wells of a moon one cycle mines: 0 or 1 one, as many as a moon can have the whole moon.
{ k: "reach", label: "Wells/cycle", type: "int", min: 0 },
{
k: "_yieldTrack",
label: "Yield track",
type: "text",
cls: "w-name",
get: (r) => r.tracks?.yield?.name ?? "",
set: (r, v) => (((r.tracks ??= {}).yield ??= { name: "", levels: [] }).name = v),
},
{
k: "_cycleTrack",
label: "Cycle track",
type: "text",
cls: "w-name",
get: (r) => r.tracks?.cycle?.name ?? "",
set: (r, v) => (((r.tracks ??= {}).cycle ??= { name: "", levels: [] }).name = v),
},
{ k: "_rate", label: "Units/h", calc: (r) => (r.claimAmount * 3600) / r.completionTimeInSecs },
{
k: "_payback",
@@ -241,17 +255,16 @@ function init() {
digits: 1,
},
],
sub: {
label: "Upgrade tiers",
rows: (r) => (r.upgrades ??= []),
// The two upgrade tracks (tracks.go): each level sets the value flat (a claim, or a
// cycle in seconds), with a price and refined parts.
subs: ["yield", "cycle"].map((kind) => ({
kind,
label: kind === "yield" ? "Yield track (claim per cycle)" : "Cycle track (seconds per cycle)",
rows: (r) => ((((r.tracks ??= {})[kind] ??= { name: "", levels: [] }).levels ??= [])),
cols: [
{ k: "tier", label: "Tier", ro: true, type: "int" },
{ k: "_level", label: "Level", calc: (u, p) => ((p.tracks?.[kind]?.levels ?? []).indexOf(u) + 1), digits: 0 },
{ k: "value", label: kind === "yield" ? "Claim" : "Cycle s", type: "num", min: kind === "yield" ? 0 : 1 },
{ k: "price", label: "Price ₥", type: "num", min: 0 },
{ k: "claimBoost", label: "Claim boost", type: "num", min: 0 },
{ k: "_yield", label: "Claim at tier", calc: (u, p) => p.claimAmount + u.claimBoost },
{ k: "speed", label: "Speed ×", type: "num", min: 0 },
// From this tier on; 0 keeps the tool's (or a lower tier's).
{ k: "reach", label: "Wells/cycle", type: "int", min: 0 },
{
k: "parts",
label: "Parts (refined)",
@@ -261,9 +274,12 @@ function init() {
},
{ k: "_partsValue", label: "Parts value ₥", calc: (u) => Object.entries(u.parts ?? {}).reduce((s, [n, q]) => s + q * unitPrice(draft, n), 0) },
],
newRow: (rows) => ({ tier: rows.length + 1, price: rows.at(-1)?.price ?? 0, claimBoost: rows.at(-1)?.claimBoost ?? 0 }),
},
newRow: () => ({ id: nextID(), name: "New tool", description: "", price: 0, image: "", claimAmount: 1, completionTimeInSecs: 3, upgrades: [] }),
newRow: (rows, p) => ({
value: rows.at(-1)?.value ?? (kind === "yield" ? p.claimAmount : p.completionTimeInSecs),
price: rows.at(-1)?.price ?? 0,
}),
})),
newRow: () => ({ id: nextID(), name: "New tool", description: "", price: 0, image: "", claimAmount: 1, completionTimeInSecs: 3, tracks: { yield: { name: "", levels: [] }, cycle: { name: "", levels: [] } } }),
copy: (r) => ({ ...clone(r), id: nextID(), name: uniqueName(r.name + " copy", draft.storeItems.map((x) => x.name)) }),
check(row, col, v) {
if (col.k === "id" && draft.storeItems.filter((r) => r.id === v).length > 1) return "Duplicate id";
@@ -340,27 +356,8 @@ function init() {
market: kvTable((path) => /^market/i.test(path[0])),
constants: kvTable((path) => !/^market/i.test(path[0])),
// Drill attachments and advisors (bonus.go). Bonuses are multipliers (1.2 = +20%); the
// caps, slots per tool and seat prices are in Constants.
attachments: {
...arrayTable("name", (d) => ((d.attachments ??= {}).items ??= [])),
noun: "attachment",
cols: [
{ k: "image", label: "Image", type: "img", dir: "attachments" },
{ k: "name", label: "Name", type: "text", required: true, cls: "w-name" },
{ k: "price", label: "Price ₥", type: "num", min: 0 },
{ k: "speed", label: "Speed ×", type: "num", min: 1 },
{ k: "yield", label: "Yield ×", type: "num", min: 1 },
{ k: "description", label: "Description", type: "long", cls: "w-desc" },
],
newRow: () => ({ name: uniqueName("New attachment", attachmentNames()), image: "", price: 0, speed: 1, yield: 1, description: "" }),
copy: (r) => ({ ...clone(r), name: uniqueName(r.name + " copy", attachmentNames()) }),
check(row, col, v) {
if (col.k === "name" && attachmentNames().filter((n) => n === v).length > 1) return "Duplicate name";
return null;
},
},
// Advisors (bonus.go). Bonuses are multipliers (1.2 = +20%); the caps and seat prices
// are in Constants.
advisors: {
...arrayTable("name", (d) => ((d.advisors ??= {}).items ??= [])),
noun: "advisor",
@@ -492,8 +489,11 @@ function init() {
if (!parent) return table.match(doc, row);
const p = table.match(doc, parent);
if (!p) return null;
const i = table.sub.rows(parent).indexOf(row);
return (p.upgrades ?? [])[i] ?? null;
for (const sub of table.subs) {
const i = sub.rows(parent).indexOf(row);
if (i >= 0) return (p.tracks?.[sub.kind]?.levels ?? [])[i] ?? null;
}
return null;
}
function cellState(table, row, col, parent) {
@@ -560,7 +560,7 @@ function init() {
const hasActions = !table.noAdd;
let html = "<thead><tr>";
if (hasFlags) html += '<th class="w-flag" aria-label="Flags"></th>';
if (table.sub) html += '<th class="w-flag" aria-label="Tiers"></th>';
if (table.subs) html += '<th class="w-flag" aria-label="Upgrade tracks"></th>';
for (const col of table.cols) {
const s = sort?.k === col.k ? (sort.dir > 0 ? " ▲" : " ▼") : "";
html += `<th class="${col.calc && !col.left ? "right " : ""}${col.nosort ? "" : "sortable"}" data-sort="${col.k}">${esc(col.label)}${s}</th>`;
@@ -568,23 +568,24 @@ function init() {
if (hasActions) html += '<th class="w-act"></th>';
html += "</tr></thead><tbody>";
const span = table.cols.length + (hasFlags ? 1 : 0) + (table.sub ? 1 : 0) + (hasActions ? 1 : 0);
const span = table.cols.length + (hasFlags ? 1 : 0) + (table.subs ? 1 : 0) + (hasActions ? 1 : 0);
for (const row of rows) {
const fl = hasFlags ? flagFor(table.flagKey(row)) : [];
html += `<tr class="${fl.length ? "has-flag" : ""}${origin.has(row) || !table.newRow ? "" : " is-new"}">`;
if (hasFlags) {
html += `<td class="w-flag">${fl.length ? `<span class="eco-flag" title="${esc(fl.join("\n"))}" tabindex="0">!</span>` : ""}</td>`;
}
if (table.sub) {
if (table.subs) {
const open = expanded.has(uid(row));
html += `<td class="w-flag"><button type="button" class="eco-x" data-act="expand" data-row="${uid(row)}" aria-expanded="${open}" title="Upgrade tiers" aria-label="Upgrade tiers of ${esc(row.name)}">${open ? "▾" : "▸"} ${table.sub.rows(row).length}</button></td>`;
const n = table.subs.reduce((s, sub) => s + sub.rows(row).length, 0);
html += `<td class="w-flag"><button type="button" class="eco-x" data-act="expand" data-row="${uid(row)}" aria-expanded="${open}" title="Upgrade tracks" aria-label="Upgrade tracks of ${esc(row.name)}">${open ? "▾" : "▸"} ${n}</button></td>`;
}
for (const col of table.cols) html += cellHTML(table, row, col);
if (hasActions) {
html += `<td class="w-act"><button type="button" class="eco-x" data-act="copy" data-row="${uid(row)}" title="Duplicate row" aria-label="Duplicate row">⧉</button><button type="button" class="eco-x eco-del" data-act="delete" data-row="${uid(row)}" title="Delete row" aria-label="Delete row">✕</button></td>`;
}
html += "</tr>";
if (table.sub && expanded.has(uid(row))) html += subHTML(table, row, span);
if (table.subs && expanded.has(uid(row))) table.subs.forEach((sub, j) => (html += subHTML(table, sub, j, row, span)));
}
if (!rows.length) html += `<tr><td colspan="${span}" class="muted">No rows${filter ? " match the filter" : ""}.</td></tr>`;
html += "</tbody>";
@@ -611,10 +612,9 @@ function init() {
}${msg ? ` title="${esc(msg)}"` : ""}>${shown ?? ""}${msg ? `<span class="eco-err">${esc(msg)}</span>` : ""}</td>`;
}
function subHTML(table, row, span) {
const sub = table.sub;
function subHTML(table, sub, j, row, span) {
let html = `<tr class="sub"><td colspan="${span}"><div class="eco-sub"><div class="eco-sub-head"><span class="cap">${esc(sub.label)} · ${esc(row.name)}</span>
<button type="button" class="btn btn-sm" data-act="add-tier" data-row="${uid(row)}">+ Add tier</button></div>
<button type="button" class="btn btn-sm" data-act="add-tier" data-sub="${j}" data-row="${uid(row)}">+ Add level</button></div>
<table class="eco-grid eco-subgrid"><thead><tr>`;
for (const col of sub.cols) html += `<th class="${col.calc ? "right" : ""}">${esc(col.label)}</th>`;
html += '<th class="w-act"></th></tr></thead><tbody>';
@@ -622,9 +622,9 @@ function init() {
rows.forEach((u, i) => {
html += "<tr>";
for (const col of sub.cols) html += cellHTML(table, u, col, row);
html += `<td class="w-act"><button type="button" class="eco-x eco-del" data-act="delete-tier" data-row="${uid(row)}" data-index="${i}" title="Delete tier" aria-label="Delete tier ${u.tier}">✕</button></td></tr>`;
html += `<td class="w-act"><button type="button" class="eco-x eco-del" data-act="delete-tier" data-sub="${j}" data-row="${uid(row)}" data-index="${i}" title="Delete level" aria-label="Delete level ${i + 1}">✕</button></td></tr>`;
});
if (!rows.length) html += `<tr><td colspan="${sub.cols.length + 1}" class="muted">No upgrade tiers.</td></tr>`;
if (!rows.length) html += `<tr><td colspan="${sub.cols.length + 1}" class="muted">No levels.</td></tr>`;
return html + "</tbody></table></div></td></tr>";
}
@@ -810,16 +810,17 @@ function init() {
break;
}
case "add-tier": {
const tiers = table.sub.rows(row);
tiers.push(table.sub.newRow(tiers));
const sub = table.subs[Number(btn.dataset.sub)];
const levels = sub.rows(row);
levels.push(sub.newRow(levels, row));
break;
}
case "delete-tier": {
const tiers = table.sub.rows(row);
const sub = table.subs[Number(btn.dataset.sub)];
const levels = sub.rows(row);
const i = Number(btn.dataset.index);
if (!(await confirmDialog(`Delete tier ${tiers[i].tier} of “${row.name}”? Higher tiers move down one.`, { title: "Delete tier", ok: "Delete", danger: true }))) return;
tiers.splice(i, 1);
tiers.forEach((u, j) => (u.tier = j + 1));
if (!(await confirmDialog(`Delete ${sub.kind} level ${i + 1} of “${row.name}”? Higher levels move down one.`, { title: "Delete level", ok: "Delete", danger: true }))) return;
levels.splice(i, 1);
break;
}
}
@@ -868,7 +869,7 @@ function init() {
$("#eco-status").innerHTML = msg ? `<span class="${error ? "eco-bad" : "eco-ok"}">${esc(msg)}</span>` : "";
}
// Server messages name rows like "store item 3 tier 2: ..." or `refinery "Void Core": ...`.
// Server messages name rows like "store item 3 yield level 2: ..." or `refinery "Void Core": ...`.
function mapErrors(list) {
rowErrors = {};
generalErrors = [];
+1 -4
View File
@@ -168,7 +168,7 @@ function play(kind, toast, prev, at) {
});
}
if (["buy-tool", "upgrade", "upgrade-batch", "buy-moon", "buy-attachment", "equip", "hire", "buy-seat", "seat-advisor", "swap-advisors"].includes(kind)) {
if (["buy-tool", "upgrade", "upgrade-batch", "buy-moon", "hire", "buy-seat", "seat-advisor", "swap-advisors"].includes(kind)) {
if (at) burst(at.x, at.y, [CYAN, CYAN_HI], 34);
// The page may change under a new moon, so sweep its list entry instead.
const moon = kind === "buy-moon" && document.querySelector("#moonlist .moon[aria-current]");
@@ -206,9 +206,6 @@ function play(kind, toast, prev, at) {
// Claim and sell sound as their numbers arrive (above).
const sound = {
"buy-tool": "buy",
"buy-attachment": "buy",
equip: "upgrade",
unequip: "toggle",
hire: "confirm",
"seat-advisor": "upgrade",
"unseat-advisor": "toggle",
+52 -96
View File
@@ -356,17 +356,17 @@
{{end}}{{end}}
{{define "equipment-section"}}
{{$open := .S.Unlocked "store"}}{{/* upgrades, attachments and buying (onboarding.go) */}}
<div class="heading"><div class="grow"><h1>Equipment</h1><p class="muted">{{plural (len .S.Tools) "tool"}}.{{if $open}} Pick one to equip attachments.{{end}}</p></div>
{{$open := .S.Unlocked "store"}}{{/* upgrades and buying (onboarding.go) */}}
<div class="heading"><div class="grow"><h1>Equipment</h1><p class="muted">{{plural (len .S.Tools) "tool"}}.{{if $open}} Each drill has two upgrade tracks: more per cycle, or shorter cycles.{{end}}</p></div>
{{if $open}}<a href="{{.V.ToSection "store"}}" hx-get="{{.V.ToSection "store"}}" class="btn">Buy tools</a>{{end}}</div>
<div class="cards">
{{range .Equipment}}
{{$row := .}}
{{$d := $.V.ToDrill .ID}}
<article class="card tool-card {{if .Selected}}is-selected{{end}}">
<a class="card-media" href="{{$d}}" hx-get="{{$d}}" aria-label="{{.Item.Name}}: {{if $open}}attachments{{else}}details{{end}}"><img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'"></a>
<a class="card-media" href="{{$d}}" hx-get="{{$d}}" aria-label="{{.Item.Name}}: {{if $open}}upgrades{{else}}details{{end}}"><img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'"></a>
<div class="card-body">
<h2><a href="{{$d}}" hx-get="{{$d}}" class="row-link">{{.Item.Name}}</a>{{if gt .Count 1}} <span class="muted num">×{{.Count}}</span>{{end}} <span class="tier-chip num" title="Tier">T{{.Tier}}/{{.MaxTier}}</span></h2>
<h2><a href="{{$d}}" hx-get="{{$d}}" class="row-link">{{.Item.Name}}</a>{{if gt .Count 1}} <span class="muted num">×{{.Count}}</span>{{end}}{{template "levels-chip" .Tool}}</h2>
<p class="tool-status small">
{{with .Stake}}
{{$url := $.StakeURL .}}
@@ -379,10 +379,7 @@
</p>
{{with .ReachText}}<p class="muted small">{{.}} per cycle</p>{{end}}
<dl class="card-stats">{{template "tool-stats" .}}</dl>
{{if $open}}
<p class="tool-gear small"><span class="pips" title="Attachment slots">{{range .Pips}}<i class="pip {{if .}}is-on{{end}}"></i>{{end}}</span>{{template "gear" .Gear}}</p>
{{template "tool-upgrade" .}}
{{end}}
{{if $open}}{{template "track-ups" .}}{{end}}
</div>
</article>
{{else}}
@@ -515,7 +512,7 @@
{{define "mk-tape-items"}}{{range .Quotes}}<a class="mk-tick" href="{{.URL}}" hx-get="{{.URL}}" tabindex="-1"><span class="mk-chip" style="{{color .Resource}}"></span><b class="num">{{.Symbol}}</b> <span class="num">{{cur .Price}}</span> <span class="num {{if .Up}}mk-up{{else}}mk-down{{end}}">{{if .Up}}▲{{else}}▼{{end}} {{.ChangeText}}</span></a>{{end}}{{end}}
{{define "store-section"}}
<div class="heading"><div class="grow"><h1>Store</h1><p class="muted">Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Equip attachments in Equipment.</p></div></div>
<div class="heading"><div class="grow"><h1>Store</h1><p class="muted">Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Upgrade them in Equipment.</p></div></div>
<h2 class="refinery-label cap">Tools</h2>
<div class="cards">
{{range .Store}}
@@ -536,29 +533,10 @@
</article>
{{end}}
</div>
{{with .Gear}}
<h2 class="refinery-label cap">Attachments</h2>
<div class="cards">
{{range .}}
<article class="card gear-card">
<div class="card-media"><img src="/static/assets/attachments/{{.Image}}" alt="" onerror="this.style.visibility='hidden'"></div>
<div class="card-body">
<h2>{{.Name}}</h2>
<p class="mod-effect num">{{.Effect}}</p>
<p class="card-desc">{{.Description}}</p>
<dl class="card-stats">
<div><dt class="cap">Spare</dt><dd class="num">{{.Spare}}</dd></div>
<div><dt class="cap">Equipped</dt><dd class="num">{{.Equipped}}</dd></div>
</dl>
<button class="btn btn-primary card-buy" hx-post="/act/buy-attachment" hx-vals="{{vals "item" .Name}}" {{if not .CanAfford}}disabled title="{{short $.S.Balance .Price}}"{{end}}>Buy · <span class="num">{{cur .Price}}</span></button>
</div>
</article>
{{end}}
</div>
{{end}}
{{end}}
{{define "gear"}}{{range .}} <span class="mod-tag" title="{{.Effect}}">{{.Name}}</span>{{end}}{{end}}
{{/* A tool's track levels as a chip after its name ("Y2 C1"), none for a base tool. */}}
{{define "levels-chip"}}{{with .Levels.Tag}} <span class="tier-chip num" title="{{$.Levels.Title}}">{{.}}</span>{{end}}{{end}}
{{define "bonus-chips"}}
<p class="bonus-chips small">{{range .}}<span class="bonus-chip num" {{if .Capped}}title="At the cap"{{end}}>{{.Label}} {{.Text}}</span>{{end}}</p>
@@ -665,7 +643,7 @@
<li>Pick a well on your moon and deploy a tool on it.</li>
<li>When the drill finishes, claim what it dug up. "Claim and redeploy" puts the tool straight back to work; after a claim, "Redrill" does the same.</li>
<li>Sell resources in the Market, upgrade your equipment, claim more moons.</li>
<li>Buy attachments in the Store and equip them on a drill (click it in Equipment) to make it faster or bigger. Hire advisors to boost your whole operation.</li>
<li>Each drill has two upgrade tracks in Equipment: one raises what a cycle mines, the other makes cycles shorter. Hire advisors to boost your whole operation.</li>
</ol>
<p class="muted">Your progress is saved on this browser.</p>
<button class="btn" hx-post="/act/new-game"
@@ -699,14 +677,13 @@
{{if .Idle}}
<div class="deploy-cards">
{{range .Idle}}
<button type="button" class="btn deploy-card{{if .Short}}{{if gt .Here 0.0}} is-short{{else}} is-spoken{{end}}{{end}}"{{if .Blocked}} disabled{{end}} hx-post="/act/stake" hx-vals="{{vals "tool" .ID "target" $.Well.ID}}" title="Deploy {{.Item.Name}} T{{.Tier}}{{range .Gear}} · {{.Name}}{{end}}">
<button type="button" class="btn deploy-card{{if .Short}}{{if gt .Here 0.0}} is-short{{else}} is-spoken{{end}}{{end}}"{{if .Blocked}} disabled{{end}} hx-post="/act/stake" hx-vals="{{vals "tool" .ID "target" $.Well.ID}}" title="Deploy {{.Item.Name}}{{.Levels.Tag}}">
<span class="deploy-card-media">
<img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'">
{{with .Gear}}<span class="deploy-card-pips pips">{{range .}}<i class="pip is-on"></i>{{end}}</span>{{end}}
{{if gt .Count 1}}<span class="deploy-card-count num">×{{.Count}}</span>{{end}}
</span>
<span class="deploy-card-info">
<span class="deploy-card-name"><span class="deploy-card-title">{{.Item.Name}}</span> <span class="tier-chip num">T{{.Tier}}</span></span>
<span class="deploy-card-name"><span class="deploy-card-title">{{.Item.Name}}</span>{{template "levels-chip" .Tool}}</span>
<span class="deploy-card-meta">
<span class="num" title="What a cycle gets here after the drills already on it{{if .Short}} (its yield is {{money .Yield}}){{end}}">+{{money .Here}} / {{secs .Secs}}</span>
{{if .ReachShort}}<span class="muted" title="{{.ReachText}}">· {{.ReachShort}}</span>{{end}}
@@ -720,7 +697,7 @@
{{with .Better}}
<div class="line better-well">
<span class="grow small">Better: <a href="{{.URL}}" hx-get="{{.URL}}">{{template "dot" .Resource}}{{.Resource}} well</a>{{if ne .MoonID $.Moon.ID}} on {{.MoonName}}{{end}}, <span class="num">{{money .Free}}</span> free</span>
<button class="btn btn-sm btn-primary" hx-post="/act/stake" hx-vals="{{vals "tool" .Tool.ID "target" .WellID "s" "moons" "moon" .MoonID "well" .WellID}}" title="Deploy {{.Tool.Item.Name}} T{{.Tool.Tier}} on that well">Deploy there</button>
<button class="btn btn-sm btn-primary" hx-post="/act/stake" hx-vals="{{vals "tool" .Tool.ID "target" .WellID "s" "moons" "moon" .MoonID "well" .WellID}}" title="Deploy {{.Tool.Item.Name}}{{.Tool.Levels.Tag}} on that well">Deploy there</button>
</div>
{{end}}
{{else}}
@@ -734,7 +711,7 @@
<div class="line drill-line is-{{.State}}">
<span class="drill-thumb">{{if .Item}}<img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'">{{end}}</span>
<span class="drill-main">
<span class="drill-name">{{if .Item}}{{.Item.Name}}{{else}}Tool{{end}} <span class="muted">T{{.Tier}}</span>{{template "gear" .Gear}}</span>
<span class="drill-name">{{if .Item}}{{.Item.Name}}{{else}}Tool{{end}}{{with .Levels.Tag}}<span class="muted lv-tag">{{.}}</span>{{end}}</span>
<span class="drill-meta small"><span class="muted"><span class="num">+{{money .Amount}}</span>/cycle{{with .ReachText}} · {{.}}{{end}}</span>
{{if eq .State "none"}}<span class="drill-tag" title="Nothing left by its turn: stops and goes idle when the well runs dry">Nothing left</span>{{else}}<span class="drill-gets">· Gets <span class="num">{{money .Here}}</span>{{if gt .Reach 1}} here{{if ne .Gets .Here}} · <span class="num">{{money .Gets}}</span> in all{{end}}{{end}}{{if eq .State "short"}} of <span class="num">{{money .Amount}}</span>{{end}}</span>{{end}}</span>
</span>
@@ -780,53 +757,59 @@
</section>
{{end}}
{{/* Side panel for a drill picked in the Equipment section (attachments.go): its slots,
and the spare attachments with the drill's stats once equipped. */}}
{{/* A tool's stats (a ToolRow), for a <dl>: each one's value now and, when the next tier
changes it, "▸ new value" in the stat-up accent. */}}
{{/* A tool's stats (a ToolRow), for a <dl>: a stake's yield and cycle now. */}}
{{define "tool-stats"}}
{{$u := .Preview}}
{{with $u}}<div class="su-tier"><dt class="cap">Tier</dt><dd class="num">T{{$.Tier}} <span class="su" title="After the upgrade">▸ <b>T{{.Tier}}</b></span></dd></div>{{end}}
<div><dt class="cap">Yield</dt><dd class="num">{{money .Yield}}{{if and $u (gt $u.YieldTo $u.YieldFrom)}} <span class="su" title="After the upgrade">▸ <b>{{money $u.YieldTo}}</b></span>{{end}}</dd></div>
{{if and $u (gt $u.ReachTo $u.ReachFrom)}}<div><dt class="cap">Reach</dt><dd class="num">{{$u.ReachFrom}} <span class="su" title="After the upgrade">▸ <b>{{wells $u.ReachTo}}</b></span> <span class="su-new">New</span></dd></div>{{end}}
<div><dt class="cap">Cycle</dt><dd class="num">{{secs .Secs}}{{if and $u (ne $u.SecsTo $u.SecsFrom)}} <span class="su" title="After the upgrade">▸ <b>{{secs $u.SecsTo}}</b></span>{{end}}</dd></div>
<div><dt class="cap">Yield</dt><dd class="num">{{money .Yield}}</dd></div>
<div><dt class="cap">Cycle</dt><dd class="num">{{secs .Secs}}</dd></div>
{{end}}
{{/* A tool's next upgrade (a ToolRow): the button and its parts. */}}
{{define "tool-upgrade"}}
{{if .Next}}
<button class="btn btn-sm card-buy {{if .CanAfford}}su-ready{{end}}" hx-post="/act/upgrade" hx-vals="{{vals "tool" .ID}}" {{if not .CanAfford}}disabled{{with .Missing}} title="{{.}}"{{end}}{{end}}>Upgrade to T{{.Next.Tier}} · <span class="num">{{cur .Next.Price}}</span></button>
{{with .Short}}<p class="up-short small num">{{.}}</p>{{end}}
{{with .Parts}}{{template "upgrade-parts" .}}{{end}}
{{else}}
<p class="muted small card-buy">Max tier</p>
{{end}}
{{/* A ToolRow's two upgrade tracks side by side (Equipment card, drill panel), each with
its level, its value now ▸ after the next level, the button and a need line. Every
box has the same rows whatever its state, so the buttons never move (clicker rule). */}}
{{define "track-ups"}}{{$id := .ID}}
<div class="track-ups">
{{range .Tracks}}
<div class="track-up">
<p class="track-up-head small"><span class="track-up-name">{{.Name}}</span> <span class="num muted" title="Level {{.Level}} of {{.Max}}">{{.Level}}/{{.Max}}</span></p>
<p class="track-up-val num small">{{trackval .Cycle .Value}}{{if .Next}} <span class="su" title="After the upgrade">▸ <b>{{trackval .Cycle .NextValue}}</b></span>{{end}}</p>
{{if .Next}}
<button class="btn btn-sm card-buy {{if .CanAfford}}su-ready{{end}}" hx-post="/act/upgrade" hx-vals="{{vals "tool" $id "track" .Kind}}" title="{{.Label}}{{with .Missing}}: {{.}}{{end}}" {{if not .CanAfford}}disabled{{end}}>Upgrade · <span class="num">{{cur .Next.Price}}</span></button>
<div class="up-need small">{{with .Parts}}{{template "upgrade-parts" .}}{{else}}{{with .Short}}<span class="up-short num">{{.}}</span>{{else}}&nbsp;{{end}}{{end}}</div>
{{else}}
<p class="btn btn-sm card-buy is-max" aria-disabled="true">Max level</p>
<div class="up-need small">&nbsp;</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{/* Side panel for a drill picked in the Equipment section (tracks.go): its stats, both
upgrade tracks with their ladders, an idle copy to deploy and selling it. */}}
{{define "drill-panel"}}
<h2>{{.Item.Name}} <span class="muted">T{{.Tier}}</span></h2>
<h2>{{.Item.Name}}{{with .Levels.Tag}}<span class="muted lv-tag">{{.}}</span>{{end}}</h2>
<dl class="facts su-facts">{{template "tool-stats" .ToolRow}}</dl>
{{/* Before the Store is unlocked: stats, tiers and deploying only (DrillPanel.Store). */}}
{{if .Store}}
{{with .Preview}}{{if gt .RateGain 0.0}}<p class="su-income small num">Income {{curk .RateFrom}}/h ▸ {{curk .RateTo}}/h{{with .Payback}} · pays back in {{.}}{{end}}</p>{{end}}{{end}}
<div class="drill-upgrade">{{template "tool-upgrade" .ToolRow}}</div>
{{end}}
{{with .Ladder}}
<h3>Tiers</h3>
<ol class="tier-ladder num small">
<li class="tl-head cap" aria-hidden="true"><span>Tier</span><span>Yield</span><span>Reach</span><span>Cycle</span><span class="tl-rate">₥/h</span></li>
{{range .}}
{{/* Before the Store is unlocked: stats, ladders and deploying only (DrillPanel.Store). */}}
{{if .Store}}<div class="drill-upgrade">{{template "track-ups" .ToolRow}}</div>{{end}}
{{range .Tracks}}
<h3>{{.Name}} <span class="muted small">{{if .Cycle}}cycle{{else}}yield per cycle{{end}}</span></h3>
{{if and $.Store .Next (gt .RateGain 0.0)}}<p class="su-income small num">Next level: income {{curk .RateFrom}}/h ▸ {{curk .RateTo}}/h{{with .Payback}} · pays back in {{.}}{{end}}</p>{{end}}
<ol class="tier-ladder track-ladder num small">
<li class="tl-head cap" aria-hidden="true"><span>Level</span><span>{{if .Cycle}}Cycle{{else}}Yield{{end}}</span><span>Price</span><span class="tl-rate">₥/h</span></li>
{{$cycle := .Cycle}}
{{range .Ladder}}
<li class="{{if .Current}}is-current{{else if .Next}}is-next{{end}}"{{if .Current}} aria-current="true"{{end}}>
<span class="tl-tier">T{{.Tier}}</span><span>{{money .Yield}}</span><span>{{wells .Reach}}</span><span>{{secs .Secs}}</span><span class="tl-rate">{{curk .Rate}}</span>
<span class="tl-tier">{{.Level}}</span><span>{{trackval $cycle .Value}}</span>
<span>{{if .Price}}{{cur .Price}}{{range .Parts}} <span title="{{.Name}}">+{{money .Need}} {{template "dot" .Name}}</span>{{end}}{{else}}—{{end}}</span><span class="tl-rate">{{curk .Rate}}</span>
</li>
{{end}}
</ol>
{{end}}
{{if .Mining}}<p class="muted small">Mining now.{{if .Store}} Changes apply from its next drill.{{end}}</p>{{end}}
{{if .Mining}}<p class="muted small">Mining now.{{if .Store}} Upgrades apply from its next drill.{{end}}</p>{{end}}
{{with .IdleBest}}
{{$b := $.IdleURL}}
<div class="line">
<span class="grow"><a href="{{$b}}" hx-get="{{$b}}">{{$.IdleSame}} idle {{$.Item.Name}}</a> <span class="muted">T{{.Tier}}</span><br>
<span class="grow"><a href="{{$b}}" hx-get="{{$b}}">{{$.IdleSame}} idle {{$.Item.Name}}</a>{{with .Levels.Tag}}<span class="muted lv-tag">{{.}}</span>{{end}}<br>
<span class="muted small">{{with $.DeployTo}}Best well: {{.Resource}} on {{.MoonName}}{{else}}No well to deploy on{{end}}</span></span>
{{with $.DeployTo}}
<button class="btn btn-sm btn-primary" hx-post="/act/stake"
@@ -836,37 +819,10 @@
{{end}}
{{if .Store}}
<h3>Slots</h3>
{{range .Slots}}
<div class="line">
<span class="cap num">{{.N}}</span>
{{with .Gear}}
<span class="grow">{{.Name}}<br><span class="muted small">{{.Effect}}</span></span>
<button class="btn btn-sm" hx-post="/act/unequip" hx-vals="{{vals "item" .Name "to" $.ID}}">Unequip</button>
{{else}}
<span class="grow muted">Empty</span>
{{end}}
</div>
{{end}}
<h3>Spare attachments</h3>
{{range .Spares}}
<div class="line">
<span class="grow">{{.Name}}{{if gt .Count 1}} <span class="muted">×{{.Count}}</span>{{end}}<br>
<span class="muted small num">{{.Effect}} · then {{secs .Secs}}, +{{money .Yield}}</span></span>
<button class="btn btn-sm btn-primary" hx-post="/act/equip" hx-vals="{{vals "item" .Name "to" $.ID}}" {{if not .CanEquip}}disabled title="{{.Why}}"{{end}}>Equip</button>
<button class="btn btn-sm btn-danger" hx-post="/act/sell-attachment" hx-vals="{{vals "item" .Name}}"
hx-confirm="{{.Confirm}}" data-confirm-title="Sell {{.Name}}" data-confirm-ok="Sell for {{cur .Resale}}" data-confirm-danger
title="Sell one for {{cur .Resale}}">Sell</button>
</div>
{{else}}
<p class="muted small">No spare attachments. <a href="/?s=store" hx-get="/?s=store">Buy some in the store</a>.</p>
{{end}}
<h3>Sell drill</h3>
<div class="line">
<span class="grow">Sells for <span class="num">{{cur .Resale}}</span><br>
<span class="muted small">Part of what it and its upgrades cost. Refined parts aren't refunded{{with .Gear}}; its attachments go back to your inventory{{end}}.</span></span>
<span class="muted small">Part of what it and its upgrades cost. Refined parts aren't refunded.</span></span>
<button class="btn btn-sm btn-danger" hx-post="/act/sell-tool" hx-vals="{{vals "tool" .ID}}"
hx-confirm="{{.SellConfirm}}" data-confirm-title="Sell drill" data-confirm-ok="Sell for {{cur .Resale}}" data-confirm-danger
{{with .NoSale}}disabled title="{{.}}"{{end}}>Sell</button>
+4 -5
View File
@@ -31,7 +31,6 @@
<a href="#resources" data-tab="resources">Resources</a>
<a href="#refined" data-tab="refined">Refined</a>
<a href="#market" data-tab="market">Market</a>
<a href="#attachments" data-tab="attachments">Attachments</a>
<a href="#advisors" data-tab="advisors">Advisors</a>
<a href="#moons" data-tab="moons">Moons</a>
<a href="#constants" data-tab="constants">Constants</a>
@@ -185,16 +184,16 @@
</table>
</div>
<h3>Upgrades · claim per cycle / payback of the extra income (price + parts at sell value)</h3>
<h3>Upgrade tracks · each level's value (the other track at 0) / payback of the extra income over the level below (price + parts at sell value)</h3>
<div class="table-box">
<table class="table eco-table">
<tbody>
{{range .Tools}}
<tr>
<td>{{.Name}}</td>
{{range .Tiers}}
<td class="num right{{if .TooBig}} eco-red{{end}}" title="Tier {{.Tier}}: {{cur .Price}}{{if .PartsValue}} + parts worth {{cur .PartsValue}}{{end}}, {{.Reach}} well(s), {{money .Eff}} on a moon, {{cur .CreditsPerHour}}/h">
T{{.Tier}} {{money .Yield}} <span class="muted">/ {{.Payback}}</span>
{{range .Levels}}
<td class="num right{{if .TooBig}} eco-red{{end}}" title="{{.Track}} level {{.Level}}: {{cur .Price}}{{if .PartsValue}} + parts worth {{cur .PartsValue}}{{end}}, {{money .Eff}} on a moon, {{cur .CreditsPerHour}}/h">
{{if eq .Track "cycle"}}C{{.Level}} {{secs .Secs}}{{else}}Y{{.Level}} {{money .Yield}}{{end}} <span class="muted">/ {{.Payback}}</span>
</td>
{{end}}
</tr>
+7 -5
View File
@@ -184,10 +184,12 @@
hx-confirm="Give this tool to {{.P.Name}}?" data-confirm-ok="Give">
<label class="grow"><span class="label">Tool</span>
<select class="input" name="item">
{{range .StoreItems}}<option value="{{.ID}}">#{{.ID}} {{.Name}} (tiers 0–{{len .Upgrades}})</option>{{end}}
{{range .StoreItems}}<option value="{{.ID}}">#{{.ID}} {{.Name}} (yield 0–{{len .Tracks.Yield.Levels}}, cycle 0–{{len .Tracks.Cycle.Levels}})</option>{{end}}
</select></label>
<label><span class="label">Tier</span>
<input class="input pl-tier" type="number" name="tier" min="0" step="1" value="0" required></label>
<label><span class="label">Yield level</span>
<input class="input pl-tier" type="number" name="yield" min="0" step="1" value="0" required></label>
<label><span class="label">Cycle level</span>
<input class="input pl-tier" type="number" name="cycle" min="0" step="1" value="0" required></label>
<button class="btn btn-primary">Give</button>
</form>
@@ -219,12 +221,12 @@
<h2 id="pl-tools-title">Tools</h2>
<div class="table-box">
<table class="table">
<thead><tr><th>Tool</th><th>Tier</th><th>Status</th><th class="right">Time left</th></tr></thead>
<thead><tr><th>Tool</th><th>Levels</th><th>Status</th><th class="right">Time left</th></tr></thead>
<tbody>
{{range .Tools}}
<tr>
<td>{{with .Item}}{{.Name}}{{else}}Item #{{.StoreItemID}}{{end}} <span class="muted num small">{{slice .ID 0 8}}</span></td>
<td class="num">{{.Tier}} / {{.MaxTier}}</td>
<td class="num" title="Yield level, cycle level (of the top)">Y{{.YieldLevel}}/{{.Max.Yield}} · C{{.CycleLevel}}/{{.Max.Cycle}}</td>
<td>{{with .Stake}}{{template "dot" .Resource}}{{.Resource}} on {{$.State.MoonName .MoonID}}{{else}}<span class="muted">Idle</span>{{end}}</td>
<td class="num right">{{if .Stake}}{{if .Remaining}}{{dur .Remaining}}{{else}}<span class="ready">Ready</span>{{end}}{{else}}—{{end}}</td>
</tr>