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 2448 additions and 3009 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, `completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined 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 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 for the higher upgrades). Each drill has two upgrade tracks of its own (Equipment: one raises
its panel in Equipment) give it a modifier that makes it faster or bigger; advisors 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 (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, the whole account. There is also a leaderboard (Leaderboard section: top miners by credits,
banned players left out). 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`, 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 `../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 (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, 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 gamepad later. Details live in each file's header comment; this file has the rules and a
map. 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. -- 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, 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 }) -- 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 LOSS = C.loss
local FLASH = C.flash local FLASH = C.flash
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true, ["buy-attachment"] = true, local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true,
equip = true, hire = true, ["upgrade-batch"] = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = 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. -- Sounds per action kind (fx.js); claim and sell sound as their numbers arrive.
local SOUND = { 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", 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", ["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade", ["upgrade-batch"] = "upgrade",
["buy-moon"] = "moon", ["sell-moon"] = "shimmer", stake = "deploy", redrill = "deploy", ["buy-moon"] = "moon", ["sell-moon"] = "shimmer", stake = "deploy", redrill = "deploy",
refine = "refine", collect = "collect", error = "error", 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) local function v3(x, y)
+15 -9
View File
@@ -53,6 +53,12 @@ function M.copy(st)
return copy(st, true) return copy(st, true)
end 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) local function find(list, id, key)
key = key or "id" key = key or "id"
for i, x in ipairs(list or {}) do for i, x in ipairs(list or {}) do
@@ -168,7 +174,7 @@ function M.tool(st, id)
return find(st.tools, id) return find(st.tools, id)
end 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) function M.idle_copy(st, id)
local t = find(st.tools, id) local t = find(st.tools, id)
if not t then if not t then
@@ -178,7 +184,7 @@ function M.idle_copy(st, id)
return t return t
end end
for _, x in ipairs(st.tools) do 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 return x
end end
end end
@@ -366,11 +372,11 @@ function M.add_stake(st, tool, moon_id, well, ctx, group)
-- Off the selected well's idle list. -- Off the selected well's idle list.
if st.well then if st.well then
for i, g in ipairs(st.well.idle or {}) do 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 g.count = g.count - 1
if g.toolId == tool.id then if g.toolId == tool.id then
for _, x in ipairs(st.tools) do 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 g.toolId = x.id
break break
end end
@@ -414,7 +420,7 @@ function M.remove_stake(st, s)
if st.well and st.well.idle then if st.well and st.well.idle then
local g = nil local g = nil
for _, x in ipairs(st.well.idle) do 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 g = x
end end
end end
@@ -424,9 +430,9 @@ function M.remove_stake(st, s)
local free = M.supply(st, st.well.id) local free = M.supply(st, st.well.id)
local y = tool.yield * yield_boost(st, st.well.resource) local y = tool.yield * yield_boost(st, st.well.resource)
st.well.idle[#st.well.idle + 1] = { 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, 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
end end
@@ -667,7 +673,7 @@ M.register("stake", function(st, p, ctx)
local group = nil local group = nil
if st.well and st.well.id == w.id then if st.well and st.well.id == w.id then
for _, g in ipairs(st.well.idle or {}) do 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 group = g
end end
end end
@@ -705,7 +711,7 @@ M.register("redrill", function(st, p, ctx)
local function group_of(t) local function group_of(t)
if st.well and st.well.id == id then if st.well and st.well.id == id then
for _, g in ipairs(st.well.idle or {}) do 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 return g
end end
end end
+1 -1
View File
@@ -49,7 +49,7 @@ M.palettes = {
loss = { "#ff8a96" }, loss = { "#ff8a96" },
teal = { "#2dd4bf" }, teal = { "#2dd4bf" },
violet = { "#b69cff" }, 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) stat_up = { "#c4a8ff" }, -- a tool stat its next upgrade raises (not money)
up = { "#3ee6a0" }, up = { "#3ee6a0" },
flash = { "#ffffff" }, -- peak of a gain flash or a scan line flash = { "#ffffff" }, -- peak of a gain flash or a scan line
-4
View File
@@ -35,17 +35,13 @@ local KIND = {
redrill = { "cyan", "▼" }, redrill = { "cyan", "▼" },
upgrade = { "cyan", "▲" }, upgrade = { "cyan", "▲" },
["buy-tool"] = { "cyan", "+" }, ["buy-tool"] = { "cyan", "+" },
["buy-attachment"] = { "cyan", "+" },
["buy-seat"] = { "cyan", "+" }, ["buy-seat"] = { "cyan", "+" },
["buy-slot"] = { "cyan", "+" }, ["buy-slot"] = { "cyan", "+" },
equip = { "cyan", "▲" },
unequip = { "muted", "▼" },
hire = { "violet", "●" }, hire = { "violet", "●" },
refine = { "violet", "◆" }, refine = { "violet", "◆" },
collect = { "teal", "◆" }, collect = { "teal", "◆" },
["sell-moon"] = { "ready", "₥" }, ["sell-moon"] = { "ready", "₥" },
["sell-tool"] = { "ready", "₥" }, ["sell-tool"] = { "ready", "₥" },
["sell-attachment"] = { "ready", "₥" },
["save-code"] = { "violet", "★" }, ["save-code"] = { "violet", "★" },
restore = { "violet", "★" }, restore = { "violet", "★" },
error = { "danger", "!" }, error = { "danger", "!" },
+16 -1
View File
@@ -333,8 +333,13 @@ function M.dur(ms)
return string.format("%d:%02d", m, sec) return string.format("%d:%02d", m, sec)
end 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) 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 if secs < 60 then
return secs .. "s" return secs .. "s"
elseif secs % 60 == 0 then elseif secs % 60 == 0 then
@@ -343,6 +348,16 @@ function M.cycle(secs)
return string.format("%dm %ds", math.floor(secs / 60), secs % 60) return string.format("%dm %ds", math.floor(secs / 60), secs % 60)
end 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) function M.plural(n, word)
return n .. " " .. word .. (n == 1 and "" or "s") return n .. " " .. word .. (n == 1 and "" or "s")
end end
+68 -282
View File
@@ -1,26 +1,22 @@
-- The aside (region, right column): the moon viewport (the selected moon in every -- 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 -- 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 -- well's facts (supply, what the drills leave, price, its drills and their times), the
-- there (a section module's aside / aside_below, see core/main.lua), always under the -- moon's when no well is picked, or what the section puts there (a section module's
-- viewport. -- 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 theme = require("modules.theme")
local ui = require("modules.ui") local ui = require("modules.ui")
local moon_view = require("modules.moon.moon") local moon_view = require("modules.moon.moon")
local reveal = require("screens.game.reveal") local reveal = require("screens.game.reveal")
local moons = require("screens.game.moons") local moons = require("screens.game.moons")
local moon_sale = require("screens.game.moon_sale")
local panel = require("components.panel") local panel = require("components.panel")
local scroll = require("components.scroll") local scroll = require("components.scroll")
local bar = require("components.bar") local bar = require("components.bar")
local tooltip = require("modules.tooltip") local tooltip = require("modules.tooltip")
local row_c = require("components.row") local row_c = require("components.row")
local button = require("components.button")
local chip = require("components.chip") local chip = require("components.chip")
local anim = require("modules.anim")
local images = require("modules.images") local images = require("modules.images")
local cards = require("screens.game.cards")
local ready = require("screens.game.core.ready")
local flex = require("modules.flex") local flex = require("modules.flex")
local fg = require("modules.flex_gui") 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") return pick and pick.short ~= "" and pick.short or (n .. " wells")
end 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 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). -- A stat line (H.fact: cap label, value at the right; 20 tall).
local function fact(H, label, value, opts) local function fact(H, label, value, opts)
return made({ height = 20 }, function(p, x, y, w) 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 M12 = { font = "mono", px = 12, color = "muted" }
local S12 = { font = "sans", 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): -- 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 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) local tool = find(st.tools, d.toolId)
return made({ height = 34 }, function(p, x, y, inner) return made({ height = 34 }, function(p, x, y, inner)
local dr = ui.rect(p, x, y, inner, 34, "none") 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 end
local x0 = 36 local x0 = 36
local nt = ui.text(dr, name, CARD_NAME, x0, 8) 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) local head = { fg.node_label(nt, 16) }
-- Tags while they fit (and leave 50 for the time). if tag ~= "" then
local head = { fg.node_label(nt, 16), space(6), fg.node_label(tt, 16), space(8) } local tt = ui.text(dr, tag:sub(2), M11, x0 + ui.text_width(nt) + 6, 8)
local tx = x0 + ui.text_width(nt) + 6 + ui.text_width(tt) + 8 head[#head + 1] = space(6)
for _, a in ipairs(tool and tool.attachments or {}) do head[#head + 1] = fg.node_label(tt, 16)
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
end end
-- The amount it gets in ready when it comes up short (engine markup runs). -- 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 "" 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)
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. -- The selected well under the viewport (one tree; it scrolls); returns its bottom.
local function well_panel(g, root, pad, top, inner, H) local function well_panel(g, root, pad, top, inner, H)
local st = g.state 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 -- 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 -- 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 -- 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 -- when there is no note, so a deploy doesn't make the facts jump.
-- deploy must not push the deploy cards down (buttons never move under the pointer).
local note = nil local note = nil
if (wrow.noteText or "") ~= "" then if (wrow.noteText or "") ~= "" then
local runs = {} local runs = {}
@@ -427,50 +257,6 @@ local function well_panel(g, root, pad, top, inner, H)
end end
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). -- Drills on this well, its own and multi-well ones, oldest first (the web's Queue).
local drills = {} local drills = {}
for _, d in ipairs(wrow.drills) do 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(col({ gap = 2 }, lines))
add(space(0, #lines > 0 and 10 or 8)) add(space(0, #lines > 0 and 10 or 8))
-- Claim (ready), Claim countdown (mining), Claim and redeploy; the multi-well claim; -- Idle drills that last mined it (Redrill on its card), and a better well for the
-- Redrill. Amounts move on as more drills finish on our clock (core/ready). -- idle drills (the server's pick), as text.
local claims = {} if (wrow.redrill or 0) > 0 then
local amount = ready.amount(g, wrow, wrow.supply) add(fact(H, "Idle, last mined here", tostring(wrow.redrill)))
if wrow.ready > 0 then add(space(0, 6))
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)
end end
if #wide_here > 0 then local better = w.better
local wamount = ready.amount(g, wide, wide.cap) if better and not dry then
if wide.ready > 0 then local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
claims[#claims + 1] = claim_row({ local name, tag = moons.tool_name(st, better.toolId)
fg.button(c, "Claim " .. ui.money(wamount), { primary = true }, function(b, bn) add(fg.text_para(c, "Better for " .. name .. tag .. ": the " .. better.resource .. " well"
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id }) .. (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free.", "small", { max_width = math.min(inner, S.text_max) }))
end), add(space(0, 6))
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))
end end
-- (no buttons here: every action on the well is on its card in the main panel)
local tree = col({ width = inner }, kids) local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, pad, top), c) fg.build(flex.solve(tree, pad, top), c)
for _, f in ipairs(enters) do for _, f in ipairs(enters) do
@@ -590,6 +336,45 @@ function M.row_viewport(w)
return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2))) return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2)))
end 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. -- A heading (h2) and a line under the viewport.
local function caption(c, x, y, inner, title, text) local function caption(c, x, y, inner, title, text)
fg.build(flex.solve(col({ width = inner }, { 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) gui.delete_node(box)
end end
if moon and st.well then 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 top = cy - 8
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id) 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) 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 elseif moon then
caption(root, pad, cy, inner, moon.name, moon.finished and "Mined out. Nothing left to dig." moon_status(g, root, pad, cy, inner, H)
or "Pick a well in the table to deploy tools and claim resources.")
elseif mod and mod.aside_below and st[section] then elseif mod and mod.aside_below and st[section] then
mod.aside_below(g, root, pad, cy, inner, limit, H) mod.aside_below(g, root, pad, cy, inner, limit, H)
elseif section == "new-moon" then elseif section == "new-moon" then
+4 -22
View File
@@ -48,14 +48,13 @@ function M.pct(m)
end end
local EFFECT_WORDS = { local EFFECT_WORDS = {
attachment = { { "speed", "speed" }, { "yield", "yield" } },
advisor = { { "speed", "drill speed" }, { "yield", "yield" }, { "sell", "sell prices" }, { "refine", "refinery speed" } }, 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) function M.effect(effect, kind)
local parts = {} 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 local m = effect and effect[e[1]] or 1
if m > 1 then if m > 1 then
parts[#parts + 1] = M.pct(m) .. " " .. e[2] parts[#parts + 1] = M.pct(m) .. " " .. e[2]
@@ -187,29 +186,12 @@ function M.stat_up(parent, label, from, to, x, y)
return w return w
end 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) 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 }) 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 return w, box
end 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 -- Refined parts an upgrade needs: "● 3/5 Dusk Alloy" chips, red when short. Returns the
-- height used. -- height used.
function M.parts(st, parent, parts, x, y, w) function M.parts(st, parent, parts, x, y, w)
@@ -282,7 +264,7 @@ end
---------------------------------------------------------------- inline confirm ---------------------------------------------------------------- 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 -- 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 -- 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 -- 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 st = g.state
local section = g.view.section local section = g.view.section
local common = local_sig(g, MAIN_READY[section] or {}) 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 {})) pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}))
blocks.keep(g, "main", sig, parent, function(holder) blocks.keep(g, "main", sig, parent, function(holder)
for _, m in pairs(M.SECTIONS) do 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) moon_view.move(g.viewport_node) -- (where the page's offset put it)
g.viewport_rect = g.fx:rect_of(g.viewport_node) g.viewport_rect = g.fx:rect_of(g.viewport_node)
end 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 v = g.view
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick) }, "|") 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 if s and want and key ~= g.page_key then
s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0)) s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0))
end end
+164 -234
View File
@@ -1,11 +1,12 @@
-- The Equipment section (state.equipment): a card per tool (copies of the same type, tier -- The Equipment section (state.equipment): a card per tool (copies of the same type and
-- and attachments share one) with its image, tier, status, stats and what the next -- track levels share one) with its image, levels chip, status, stats (yield, cycle) and
-- upgrade gives (stat_up violet, apart from money), attachment pips and tags, and the -- its two upgrade tracks side by side (tracks.go): each track's name and level, its value
-- upgrade button with its parts. A card opens the drill panel (view drill=<tool id>) in -- now ▸ after the next level (stat_up violet, apart from money), the Upgrade button (or
-- the aside: stats, upgrade, tier ladder, slots (unequip), spare attachments (equip, with -- Max level in its place) and a need line kept whether it shows, so the buttons never
-- the stats they'd give; sell), idle copies and their best well, and selling the drill. -- move (clicker rule). A card opens the drill panel (view drill=<tool id>) in the aside:
-- Before the Store is unlocked (onboarding) cards and panel leave out the Store's parts -- stats, the two tracks, each track's ladder, idle copies and their best well, and
-- (attachments, upgrades, selling): the panel is stats, tiers and idle copies. -- 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 theme = require("modules.theme")
local ui = require("modules.ui") local ui = require("modules.ui")
@@ -32,36 +33,22 @@ local function find(list, key, value)
end end
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) local function drill_view(self, id)
return { section = "equipment", drill = id, moon = self.view.moon, well = self.view.well } return { section = "equipment", drill = id, moon = self.view.moon, well = self.view.well }
end end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- Stats with the next upgrade's changes: Tier, Yield, Reach (when it grows), Cycle, -- Stats: Yield and Cycle (a stake's now), wrapping (rows 20 tall, 16 apart). A tree piece.
-- wrapping (rows 20 tall, 16 apart). A tree piece.
local function stats_box(st, parent, t) local function stats_box(st, parent, t)
local u = t.preview local items = { { "Yield", ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) } }
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 kids = {} local kids = {}
for _, it in ipairs(items) do for _, it in ipairs(items) do
local probe = ui.rect(parent, 0, 0, 1, 1, "none") 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) gui.delete_node(probe)
kids[#kids + 1] = made({ width = iw, height = 20, shrink = 0 }, function(p, x, y) 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)
end end
return row({ wrap = true, column_gap = 16 }, kids) return row({ wrap = true, column_gap = 16 }, kids)
@@ -75,56 +62,95 @@ local function stats(st, parent, t, x, y, w)
return tree.h return tree.h
end end
-- The upgrade button (or "Max tier"), what's short and the parts. A tree piece w wide. -- A track's value: "+5" (yield per cycle) or "2.6s" (cycle).
local function upgrade_box(self, H, st, parent, t, w, small) local function track_value(tr, v)
local n = t.next 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 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 end
local ok = n.canAfford local ok = n.canAfford
local kids = { -- The label that fits the column: "Upgrade · ₥1,100", else "Up · ₥1,100".
fg.button(parent, "Upgrade to T" .. n.tier, { small = small, price = n.price, disabled = not ok }, function(b, nodes) local label = "Upgrade"
if not ok then if fg.button_w(parent, label, { small = true, price = n.price }) > w then
tooltip.add(b, n.blockedText) label = "Up"
else 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) cards.pulse(nodes)
H.action(self, b, nodes, "upgrade", { tool = t.id }) H.action(self, b, nodes, "upgrade", { tool = t.id, track = tr.kind })
end end
end, { width = w }), end, { width = w })
space(0, 4), -- Credits short: the server's shortText (right-aligned, like the store's); else blank.
} local why = n.blocked == "credits" and (n.shortText or "") or ""
-- Credits short: the server's shortText under the button (parts short: the tooltip only). kids[#kids + 1] = made({ height = 18, shrink = 0 }, function(p, x, y, bw)
local why = n.blocked ~= "" and (n.shortText or "") or ""
if why ~= "" then if why ~= "" then
local style = { font = "sans", px = 12, color = "danger" } store.need_text(p, why, x + bw, y + 9)
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 end
return math.max(18, lh + 2)
end) end)
elseif small then kids[#kids + 1] = made({ height = parts_h, shrink = 0 }, function(p, x, y, bw)
-- A card keeps the need line's place (like the store's): the buttons in a row of cards.parts(st, p, n.parts, x, y, bw)
-- 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) end)
end
return col({ width = w }, kids) return col({ width = w }, kids)
end 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. -- 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 function tracks(self, H, st, parent, t, x, y, w)
local tree = upgrade_box(self, H, st, parent, t, w, small) local tree = tracks_box(self, H, st, parent, t, w)
fg.build(flex.solve(tree, x, y), parent) fg.build(flex.solve(tree, x, y), parent)
return tree.h return tree.h
end end
@@ -169,7 +195,8 @@ end
local function tool_card(self, H, st, c, t, open) local function tool_card(self, H, st, c, t, open)
c:image(t.image) 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) c:block(22, function(p, bw)
local cnt = t.count > 1 and ("×" .. t.count) or nil local cnt = t.count > 1 and ("×" .. t.count) or nil
fg.build(flex.solve(row({ width = bw, height = 16, align = "center" }, { 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 space(6) or nil,
cnt and fg.label(p, cnt, { font = "mono", px = 13, color = "muted" }, 16) or nil, cnt and fg.label(p, cnt, { font = "mono", px = 13, color = "muted" }, 16) or nil,
fg.fill(), space(8), fg.fill(), space(8),
fg.inline(p, 16, function(q, x, y, probing) tag ~= "" and 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 }) 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 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 end
return cwid return cwid
end), end) or nil,
}), 0, 0), p) }), 0, 0), p)
end) end)
c:block(t.reach > 1 and 22 or 28, function(p, bw) 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)) H.navigate(self, drill_view(self, t.id))
end end
if open then if open then
-- Attachments: slot pips over the image's corner, tags under the stats. -- The two upgrade tracks: measured on a probe; the footer sits at the row's bottom.
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.
local probe = ui.rect(c.root, 0, 0, 1, 1, "none") 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) gui.delete_node(probe)
c:footer(fh, function(f, bw) c:footer(fh + 2, function(f, bw)
upgrade(self, H, st, f, t, 0, 0, bw, true) tracks(self, H, st, f, t, 0, 2, bw)
end) end)
end end
c.selected = t.selected and self.view.drill ~= "" 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 local c = area.content
y = fg.flow(c, x, y, inner, { col({ width = inner }, { -- (full width: Buy tools sits at its right edge) y = fg.flow(c, x, y, inner, { col({ width = inner }, { -- (full width: Buy tools sits at its right edge)
fg.label(c, "Equipment", "h1", 40), fg.label(c, "Equipment", "h1", 40),
fg.label(c, ui.plural(#st.tools, "tool") .. "." .. (open and " 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), { font = "sans", px = 15, color = "muted" }, 12),
space(0, 18), space(0, 18),
open and fg.button(c, "Buy tools", { small = true }, function(b) 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 } 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 -- 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 tier and the next lit. -- inner width, ₥/h at the right), the current level and the next lit.
local function ladder(st, c, d, inner) local function ladder(st, c, tr, inner)
local fr = { 0, 0.2, 0.42, 0.66, 1 } local fr = { 0, 0.16, 0.4, 0.84 }
local cols = {} 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]) cols[i] = math.floor((inner - 16) * fr[i + 1]) - math.floor((inner - 16) * fr[i])
end end
cols[5] = "1fr" cols[4] = "1fr"
local heads = {} 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) heads[i] = fg.label(c, lbl, TIER_HEAD, 12)
end end
local head = box({ columns = cols, height = 14, padding = { 0, 8, 0, 8 }, align = "start" }, heads) 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) -- (₥/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 rate_head = fg.label(c, theme.currency .. "/h", TIER_HEAD, 12, { position = "absolute", top = 0, left = inner - 30 })
local rows = {} 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 color = r.current and "text" or (r.next and "stat_up" or "text_2")
local font = r.next and "mono_semi" or "mono" local font = r.next and "mono_semi" or "mono"
local kids = {} local price = "—"
for i, v in ipairs({ "T" .. r.tier, ui.money(r.yield), cards.wells(st, r.reach), ui.cycle(r.cycleSecs) }) do local full
kids[i] = fg.label(c, v, { font = font, px = 12, color = color }, 20) 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 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" }) if #parts > 0 then
price = price .. " + " .. table.concat(parts, ", ")
full = theme.currency .. ui.money(r.price) .. " + " .. table.concat(parts, ", ")
end
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 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) 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) 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 if not t then
return false return false
end end
local open = st.unlocked.store -- upgrade, attachments, selling (onboarding.go) local open = st.unlocked.store -- upgrades, selling (onboarding.go)
local pad = 18 local pad = 18
local inner = w - pad * 2 local inner = w - pad * 2
local area = scroll.area(self, root, 0, 2, w, h - 4, "drill") 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 kids[#kids + 1] = b
end end
-- Head: image, name, tier (and "Mining now"), close. -- Head: image, name, track levels (and "Mining now"), close.
local img = 84 local img = 84
local tw = inner - img - 14 local tw = inner - img - 14
add(row({ height = img }, { add(row({ height = img }, {
@@ -368,10 +396,12 @@ function M.aside(self, root, w, h, H)
space(14), space(14),
col({ grow = 1, shrink = 1 }, { col({ grow = 1, shrink = 1 }, {
fg.label(c, t.name, "h2", 28, { fit = true, shrink = 1, max_width = tw - 40 }), 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 space(0, 4) or nil,
d.mining and fg.measured(c, function(p, x, y, bw) 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, end) or nil,
}), }),
fg.button(c, "×", { small = true, w = 32 }, function(b) 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)) 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) })) 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 if open then
add(space(0, 4)) add(space(0, 4))
add(upgrade_box(self, H, st, c, t, inner, false)) add(tracks_box(self, H, st, c, t, inner))
end end
add(space(0, 8)) add(space(0, 8))
-- Tier ladder. -- Each track's ladder, with what its next level adds per hour.
if #(d.ladder or {}) > 0 then local m12 = { font = "mono", px = 12, color = "muted" }
add(h3(c, "Tiers")) for _, tr in ipairs(t.tracks or {}) do
add(ladder(st, c, d, inner)) 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 end
-- Idle copies while this one mines: deploy one on its best well. -- 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) gui.delete_node(probe.node)
add(col({ height = 60 }, { add(col({ height = 60 }, {
row({ height = 20, align = "center" }, { 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) 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") H.text_link(self, p, link, lstyle, x, y + 10, drill_view(self, d.idleBest), "idle-copy")
end), end),
space(6), 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), 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) 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 end
-- The Store's parts: slots, spare attachments, selling the drill. -- The Store's part: selling the drill.
if open then 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. -- Sell the drill.
add(h3(c, "Sell drill")) add(h3(c, "Sell drill"))
local why = d.noSale ~= "" and d.noSaleText or nil local why = d.noSale ~= "" and d.noSaleText or nil
local note = "Part of what it and its upgrades cost. Refined parts aren't refunded" 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 ".")
-- Sell asks in place (cards.ask_button): the question under the price instead of the -- 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. -- 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 }) 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 -- 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 -- 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 -- 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 -- 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 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) function M.box(self, H, c, moon, w)
local sale = moon.sale local sale = moon.sale
self.sale_open = self.sale_open or {} self.sale_open = self.sale_open or {}
+467 -210
View File
@@ -1,8 +1,9 @@
-- The Moons section (main panel): the selected moon (id, name, class and rating stars, -- 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 / -- description, Claim N drills), its wells as cards with every action on a well (deploy,
-- redrill per well, the multi-well row); a mined-out -- claim, claim and redeploy, redrill: "well cards" below), the multi-well card, the tray
-- moon's summary; the first-moon pitch when there is none. Also the well helpers the -- of idle drills and selling the moon; a mined-out moon's summary; the first-moon pitch
-- aside's well panel shares (supply_bar, supply_max, tool_name). -- 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 theme = require("modules.theme")
local ui = require("modules.ui") local ui = require("modules.ui")
@@ -15,6 +16,10 @@ local scroll = require("components.scroll")
local ready = require("screens.game.core.ready") local ready = require("screens.game.core.ready")
local flex = require("modules.flex") local flex = require("modules.flex")
local fg = require("modules.flex_gui") 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 local S = theme.size
@@ -100,10 +105,10 @@ function M.track_drills(g)
end end
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) function M.tool_name(st, id)
local t = find(st.tools, 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 end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
@@ -169,219 +174,459 @@ local function past_moon(g, root, px, py, inner, moon, H)
}) })
fg.build(flex.solve(tree, px, py), root) fg.build(flex.solve(tree, px, py), root)
end 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, ---------------------------------------------------------------- well cards
-- none); a multi-well drill has a diamond cap. At most 4 bars, then "+N" beside them. -- The wells as cards (Drilling B · Well cards): every action on a well is on its card,
-- row: the row's key (a multi-well drill has a bar on every row it reaches). y is the -- the aside only shows. A card: the resource and its supply, the supply bar and a line
-- stack's middle. -- of what the drills leave, then a stack of slots, all SLOT_H tall: the deploy slot
local MAX_BARS, BAR_W, BAR_H, BAR_GAP = 4, 40, 3, 2 -- first (it keeps its spot while you deploy again and again: new drills go under it),
local MORE = { font = "mono", px = 11, color = "muted" } -- then a slot per drill, oldest first (time left and its bar; when it's done, gold with
local function drill_bars(g, parent, x, y, drills, row_key) -- Claim and Redeploy on it), then one slot for the idle drills that last mined the well
local n = math.min(#drills, MAX_BARS) -- (Redrill). Claiming one slot claims every ready drill of the well (claim-well); the
local top = y - math.floor((n * (BAR_H + BAR_GAP) - BAR_GAP) / 2) -- next slot slides into its place. Clicking the card (not a button) selects the well:
for i = 1, n do -- the aside shows its facts.
local d = drills[i] --
local color = d.state == "short" and "ready" or "cyan" -- Deploying: the tray under the cards lists the idle drills (grouped like copies: same
local by = top + (i - 1) * (BAR_H + BAR_GAP) -- name, track levels, yield, cycle and reach). A well's deploy slot sends the drill picked in the
-- Keyed by row and tool (one drill each), so a restarted or corrected drill eases. -- tray (g.deploy_pick), or with none picked the first one-well drill in the tray that
bar.timer(g, parent, x, by, BAR_W, BAR_H, { start = d.start, ends = d["end"], ready = d.ready, color = color, -- has room there (smallest first; predict.room: what it would get after the moon's drills). Picking is
none = d.state == "none", key = "bar:" .. row_key .. ":" .. d.toolId }) -- a toggle; the pick is forgotten once that group is used up.
if (d.reach or 1) > 1 then
-- The diamond cap (app.css .drill-bar.is-wide::after). local SLOT_H, SLOT_GAP = 60, 6
local cap = ui.rect(parent, 0, 0, 5, 5, d.state == "none" and "muted" or color) local CARD_MIN, CARD_GAP, CARD_PAD = 210, 12, 12
gui.set_pivot(cap, gui.PIVOT_CENTER) local TILE_MIN, TILE_H = 150, 52
gui.set_position(cap, vec(x + BAR_W + 1, -(by + 1))) local SLOT_BTN_H = 26
gui.set_rotation(cap, vmath.quat_rotation_z(math.rad(45))) 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 end
end end
local function drill_label(st, drills) -- An action button's test id (core/hooks: "act:<name>?<sorted params>").
if #drills == 0 then local function act_id(name, params)
return "" local keys = {}
elseif #drills == 1 then for k, v in pairs(params) do
local name, tier = M.tool_name(st, drills[1].toolId) keys[#keys + 1] = k .. "=" .. tostring(v)
return name:match("(%S+)$") .. " T" .. tier
end end
return ui.plural(#drills, "drill") table.sort(keys)
return "act:" .. name .. "?" .. table.concat(keys, "&")
end end
-- The drill column of a row, built at its width (52 tall): the stacked timer bars for -- A one-line label box in a slot or tile (cut with … when it doesn't fit).
-- every drill on it, multi-well ones included (the web's Queue), and the label of its local function fit(p, str, style, opts)
-- own drills (DrillLabel) beside them, or under them (after "+N") when there's no room opts = opts or {}
-- (decided at its width). Its tooltip (DrillsTitle): each drill, oldest first, and what opts.fit, opts.shrink = true, opts.shrink or 1
-- it gets. pad: room kept before the status buttons. return fg.label(p, str, style, 16, opts)
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 end
local n = math.min(#queue, MAX_BARS)
local stack_h = n * (BAR_H + BAR_GAP) -- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a
local more = #queue - n -- cycle gets here, or why it can't (no idle drills, no room).
local function bars(beside) local function deploy_slot(g, st, well, groups, H)
local more_l = beside and more > 0 and fg.label(p, "+" .. more, MORE, 16) or nil local grp, here, amount, explicit = deploy_choice(g, st, well, groups)
return row({ shrink = 0, align = "center" }, { local ok = grp ~= nil and here > EPS
made({ width = BAR_W + 4, height = stack_h, shrink = 0 }, function(q, bx, by) return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
drill_bars(g, q, bx, by + math.floor(stack_h / 2), queue, row_key) local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not ok })
end), local title, sub, sub_color
more_l and space(2) or nil, more_l, if #groups == 0 then
}) title, sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
end elseif not grp then
local tree title, sub = "No room here", "The drills on it take all it has"
if #queue == 0 then elseif not ok then
tree = row({ height = h, align = "center" }, { title, sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
fg.label(p, "Empty", { font = "sans", px = 14, color = "muted" }, 16, { fit = true, shrink = 1 }),
})
else else
local style = { font = "sans", px = 12, color = "text_2" } local t = grp.tool
local probe = fg.label(p, label, style, 16) local reach = reach_short(st, t.reach)
local lw = probe.measure() title = "+ Deploy " .. M.short_name(t)
gui.delete_node(probe.node) sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
if BAR_W + 4 + (more > 0 and 18 or 0) + 6 + lw <= dw then if here < amount - EPS then
tree = row({ height = h, align = "center" }, { sub, sub_color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
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 }),
})
end end
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)
end end
-- Table columns (468 wide at 1280x800; wider tables spread the columns): the supply -- A drill's slot (SLOT_H tall; slides in when it was just deployed): its short name and
-- column up to the web's 200 px, the drill column takes what the row's buttons leave. -- time left over its timer bar and what it gets; done (ready, or its end passed on our
local function table_cols(inner) -- clock): gold, "+X ready", Claim (scope: claim-well or claim-wide) and, once unlocked,
local c2 = math.max(118, math.floor(inner * 0.25)) -- Redeploy. n: its place among the group's ready slots (the first one's buttons have
return { c2, math.max(92, math.min(212, math.floor(inner * 0.2))), "1fr", "auto" } -- 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
-- 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 end
-- A drill was just deployed here: the row slides in (fx-new). 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 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
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() enters[#enters + 1] = function()
row_c.enter(g, "well:" .. key, r, w, h, { base = gui.get_color(r) }) row_c.enter(g, "drill:" .. d.toolId, body, w, SLOT_H, { base = gui.get_color(body) })
end end
return r end)
end end
-- Drill queue: its own drills, and multi-well ones that reach it, oldest first. -- The slot of the idle drills that last mined the well (well.redrill): how many, and
local drills = group.drills or {} -- Redrill (sends them out again) once unlocked and the well has supply.
local queue = {} local function idle_slot(g, st, well, H)
for _, d in ipairs(drills) do local n = well.redrill or 0
queue[#queue + 1] = d local names = {}
end for _, t in ipairs(st.tools or {}) do
if not well.wide_group and moon.wide then if #names < 2 and not t.stakeId and predict.memo.last_well[t.id] == well.id then
local reach = {} names[#names + 1] = M.short_name(t)
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
end end
end end
table.sort(queue, function(a, b) return (a.start or 0) < (b.start or 0) end) local who = #names > 0 and (table.concat(names, ", ") .. (n > #names and (" +" .. (n - #names)) or "")) or ui.plural(n, "drill")
end local can = st.unlocked.redrill and well.supply > 0
local fates = {} return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
for _, d in ipairs(queue) do local body = ui.rect(p, x, y, w, SLOT_H, "cyan", 0.02)
fates[#fates + 1] = d.fate ui.outline(body, 0, 0, w, SLOT_H, "line_hi")
end local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
local label = (group.label or "") ~= "" and group.label or drill_label(st, drills) -- (the server's DrillLabel) row({ height = 16, align = "center" }, {
fit(body, who, { font = "sans_semi", px = 13, color = "text_2" }), fg.fill(), space(6),
-- Name and supply cells. fg.label(body, "Idle", { font = "sans_semi", px = 12, color = "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 }),
local ML = { font = "mono", px = 12, color = "muted" } }),
local name_cell, supply_cell can and fg.button(body, "Redrill" .. (n > 1 and (" " .. n) or ""), { small = true, variant = "redrill", h = SLOT_BTN_H }, function(b, bn)
if well.wide_group then H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
name_cell = row({ padding_left = 26, align = "center" }, { fg.label(c, "Multi-well", "cap", 16) }) tooltip.add(b, "Send the drills that last mined this well out again")
-- "X left on the moon", wrapping onto a second line when the column is narrow. end, { height = SLOT_BTN_H }) or row({ height = SLOT_BTN_H, align = "center" }, {
supply_cell = col({ padding_right = 8, justify = "center" }, { fit(body, well.supply > 0 and "Last mined here" or "Last mined here · the well is empty", S11),
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 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 dry = well.supply <= 0
name_cell = row({ padding_left = 26, align = "center" }, { local selected = well.id == g.view.well
made({ position = "absolute", left = 10, top = 22, width = 8, height = 8 }, function(q, x, y) local scope = { ["claim-well"] = well.id }
ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1) local slots = {}
end), if not dry then
fg.label(c, well.resource, { font = "sans", px = 15, color = dry and "muted" or "text" }, 16), 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 if dry then
supply_cell = row({ align = "center" }, { ui.rect(q, x, y, w, 6, "line")
fg.label(c, "Tapped out", { font = "sans_semi", px = 12, color = "muted", tracking = 0.1, upper = true }, 16),
})
else else
local sup = fg.label(c, ui.money(well.supply), ML, 16) M.supply_bar(q, x, y, w, well, max)
sup.done = function(n) H.tick_num(g, n, "supply:" .. well.id, well.supply) end end
supply_cell = col({ padding = { 20, 14, 0, 0 } }, { end),
made({ height = 6 }, function(q, x, y, w) M.supply_bar(q, x, y, w, well, max) end), space(0, 4),
space(0, 2), sup, 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 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 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 })
end
local function rating_chip(c, r) local function rating_chip(c, r)
-- The web's rating chip ("★3" in the tier's color; its tooltip the label and percentile). -- The web's rating chip ("★3" in the tier's color; its tooltip the label and percentile).
@@ -412,8 +657,8 @@ local function rating_chip(c, r)
end end
-- The moon: heading (id, name, class and rating, description; Claim at the right), the -- 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 -- well cards, the multi-well card, the tray of idle drills, and selling the moon (once
-- bottom. -- 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 function moon_view(g, c, px, py, inner, min_h, H)
local st = g.state local st = g.state
local moon = st.moon local moon = st.moon
@@ -456,32 +701,43 @@ local function moon_view(g, c, px, py, inner, min_h, H)
end) end)
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 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 enters = {}
local tbl = { local cards = {}
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),
}
for _, well in ipairs(moon.wells) do 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 end
if moon.wide then local kids = {
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 }, {
col({ min_height = 80, align = "start" }, head), col({ min_height = 80, align = "start" }, head),
space(0, 14), 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) fg.build(flex.solve(page, px, py), c)
for _, f in ipairs(enters) do for _, f in ipairs(enters) do
f() f()
@@ -489,6 +745,7 @@ local function moon_view(g, c, px, py, inner, min_h, H)
return page.y + page.h return page.y + page.h
end end
-- Section contract (AGENTS.md, "Game screen"): root is the main panel, (px, py) its -- Section contract (AGENTS.md, "Game screen"): root is the main panel, (px, py) its
-- padding, w x h its size. -- padding, w x h its size.
function M.build(g, root, px, py, w, h, H) function M.build(g, root, px, py, w, h, H)
+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 -- 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). -- (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)
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) function M.build(self, root, px, py, w, h, H)
local st = self.state local st = self.state
local sto = st.store local sto = st.store
@@ -217,11 +197,10 @@ function M.build(self, root, px, py, w, h, H)
local function cap(label, before) local function cap(label, before)
return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) }) return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) })
end end
local attachments = #(sto.attachments or {}) > 0
y = fg.flow(c, x, y, inner, { y = fg.flow(c, x, y, inner, {
fg.col({}, { fg.col({}, {
fg.label(c, "Store", "h1", 40), fg.space(0, 4), 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) }), { font = "sans", px = 15, color = "muted" }, { max_width = math.min(inner, S.text_max) }),
fg.space(0, 20), 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) tool_card(self, H, st, cd, t)
end, { key = function(t) return "store:tool:" .. tostring(t.storeItemId) end, sig = shared }) end, { key = function(t) return "store:tool:" .. tostring(t.storeItemId) end, sig = shared })
end, 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) area:finish(y + py)
end 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", ...} Prints one JSON line: {"player", "moon", "well", "wells", "tool", "drill_mining", ...}
that scenarios (scripts/drive.py) use for views. The player: every section unlocked, 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, 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. --plain: just a new player (nothing granted), for the first-moon views.
""" """
import json import json
@@ -66,8 +66,8 @@ admin("credits", amount=500000, op="grant")
for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance", for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance",
"Dusk Alloy", "Cryo Cell", "Plasma Weave"]: "Dusk Alloy", "Cryo Cell", "Plasma Weave"]:
admin("resources", resource=r, amount=400) admin("resources", resource=r, amount=400)
for item, tier in [(1, 0), (1, 0), (2, 1), (3, 0), (5, 2)]: for item, yl, cl in [(1, 0, 0), (1, 0, 0), (2, 0, 1), (3, 0, 0), (5, 2, 1)]:
admin("tool", item=item, tier=tier) admin("tool", item=item, **{"yield": yl, "cycle": cl})
act("buy-moon") act("buy-moon")
d = 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[2][0]["id"], target=wells[1 % len(wells)]["id"])
act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"]) act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"])
act("buy-attachment", item="Turbo Core") # The idle PD-1 a level up on each track (its yield track twice).
act("buy-attachment", item="Diamond Bit")
act("buy-attachment", item="Diamond Bit")
idle = by_item[1][1]["id"] 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("buy-slot")
act("refine", recipe="Dusk Alloy", qty=5) 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: """The drill panel during onboarding (needs --fixture-file of a plain player:
scripts/fixture.py <api> --plain, and an MM_DEV=1 server): once the first deploy reveals scripts/fixture.py <api> --plain, and an MM_DEV=1 server): once the first deploy reveals
Equipment, a tool card opens its panel (stats, tiers) without the Store's parts (upgrade, Equipment, a tool card opens its panel (stats, the tracks' ladders) without the Store's
slots, spares, Sell drill); after the first claim and sale reveal the Store, the same panel parts (the upgrade buttons, Sell drill); after the first claim and sale reveal the Store,
has them. Shots "before" and "after", each linted.""" the same panel has them. Shots "before" and "after", each linted."""
import os import os
import sys import sys
@@ -41,10 +41,10 @@ def run(game, args):
game.shot("before") game.shot("before")
game.lint("before") game.lint("before")
texts = labels(game) texts = labels(game)
game.expect("tiers" in texts, "tier ladder shown") game.expect("yield per cycle" in texts and "level" in texts, "track ladders shown")
for t in ("slots", "spare attachments", "sell drill"): game.expect("sell drill" not in texts, "sell drill hidden before the Store")
game.expect(t not in texts, "%s hidden before the Store" % t) ups = [e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?")]
game.expect(not any(t.startswith("upgrade to") for t in texts), "no upgrade before the Store") game.expect(not ups, "no upgrade before the Store: %s" % ups)
# First claim (Market), first sale (Store). # First claim (Market), first sale (Store).
admin(game, "finish-stakes") admin(game, "finish-stakes")
@@ -60,5 +60,8 @@ def run(game, args):
game.shot("after") game.shot("after")
game.lint("after") game.lint("after")
texts = labels(game) 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) 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, 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 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 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 json
import os import os
import subprocess import subprocess
@@ -98,9 +98,9 @@ def run(game, args):
burst(game, "newmoon", [0.05, 0.3, 0.6, 1.2]) burst(game, "newmoon", [0.05, 0.3, 0.6, 1.2])
game.idle() game.idle()
# Deploy cards: tools idle after claiming moon 1; a well with little left: a stake # Deploying: tools idle after claiming moon 1; a well with little left: a deploy from
# from the well panel (the new drill enters in the table and the panel), then the # its card's slot (the new drill enters in the card and the well panel), then the slot
# other cards come up short or spoken for. # comes up short ("Only +N left here"), then "No room here" once nothing is left.
game.navigate("{section='moons', moon='%s'}" % fx["moon"]) game.navigate("{section='moons', moon='%s'}" % fx["moon"])
game.idle() game.idle()
if game.eval("st.ready") > 0: 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.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
game.idle() game.idle()
game.shot("deploy") game.shot("deploy")
# (ids from the state: the elements reply can be long) slot = "act:stake?target=%s" % well
def card(i): first = next((e["id"] for e in game.elements() if e["id"].startswith(slot)), None)
tool = game.eval("st.well.idle[%d] and st.well.idle[%d].toolId" % (i, i)) game.expect(first, "the well's deploy slot is there")
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)
if first: if first:
game.scroll_to(first)
game.click(first) game.click(first)
burst(game, "newdrill", [0.05, 0.3, 0.6, 1.2]) burst(game, "newdrill", [0.05, 0.3, 0.6, 1.2])
game.idle() game.idle()
game.shot("deploy-short", settle=0.5) 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) psql("update resource_well set supply = 2 where id = '%s'" % well)
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well)) game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
game.idle() game.idle()
game.shot("deploy-spoken", settle=0.5) 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 """The moon view and well panel (needs --fixture and a local dev server whose database the
whose database the scenario may change: MM_DB, see lists.py): the wells table (stacked scenario may change: MM_DB, see lists.py): the well cards (a card per well, the rating
drill bars, Manage on every row, the rating chip), drills that get nothing (amber dashed chip), drills that get nothing (amber dashed bars on their slots; in the aside the NOTHING
bars, the NOTHING LEFT tag, a greyed picture), a tapped-out well's panel, and the moon LEFT tag, a greyed picture), a tapped-out well, and the moon sale opened (its lines scroll
sale opened (its lines scroll with the moon). Args: an optional name prefix.""" with the moon). Args: an optional name prefix."""
import os import os
import sys import sys
@@ -19,13 +19,13 @@ def run(game, args):
game.idle() game.idle()
game.shot(pre + "table", settle=0.8) game.shot(pre + "table", settle=0.8)
ids = [e["id"] for e in game.elements()] ids = [e["id"] for e in game.elements()]
wells = [w for w in fx["wells"] if ("manage:" + w) in ids] wells = [w for w in fx["wells"] if ("well:" + w) in ids]
game.expect(len(wells) == len(fx["wells"]), "Manage on every well row (%d of %d)" % (len(wells), len(fx["wells"]))) 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. # Drills that get nothing: six more on a well, then the well holds almost nothing.
well = fx["wells"][1 % len(fx["wells"])] well = fx["wells"][1 % len(fx["wells"])]
for _ in range(6): 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"] 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] idle = [t["id"] for t in st["tools"] if t["storeItemId"] == 2 and not t.get("stakeId")][:6]
for tid in idle: for tid in idle:
+7 -7
View File
@@ -1,13 +1,13 @@
"""Parity polish (needs --fixture). Phases (args, default all but ready): """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 bars: six more drills on a well: their slots on the well's card, the multi-well card,
diamond caps, the well panel in the web's order (deploy, drills, then the claims). 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 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 held back; the header badge, Claim all, moon row badge and Claim buttons count up on
our clock ("Claim N drills", "Claim <amount>"). our clock ("Claim N drills", "Claim <amount>").
motion: the header's reduced-motion toggle (run with --config mm.reduced_motion=0: forced, 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. so nothing is saved), the still ticker, a reduced reveal.
links: "N idle <tool>" opens that copy, "Better" well link, market card hover, refinery links: "N idle <tool>" opens that copy, market card hover, refinery recipes (the
recipes.""" "Better" well is plain text in the well panel now: its link shot is skipped)."""
import json import json
import time import time
import urllib.request import urllib.request
@@ -47,7 +47,7 @@ def idle_tools(game, item):
def stake_many(game, item, n, well): def stake_many(game, item, n, well):
for _ in range(n): 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]: for tid in idle_tools(game, item)[:n]:
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well}) r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
game.expect(r.get("ok"), "stake: %s" % r.get("error")) game.expect(r.get("ok"), "stake: %s" % r.get("error"))
@@ -84,7 +84,7 @@ def ready(game):
free = [w] free = [w]
well = free[0]["id"] well = free[0]["id"]
for _ in range(2): 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] copies = idle_tools(game, 3)[:2]
for tid in copies: for tid in copies:
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well}) r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
@@ -143,7 +143,7 @@ def links(game):
fx = game.fixture fx = game.fixture
# Two LunarLite L100 (10 s cycles), one mining: its panel links to the idle one. # Two LunarLite L100 (10 s cycles), one mining: its panel links to the idle one.
for _ in range(2): 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) copies = idle_tools(game, 3)
r = call(game, "POST", "/api/act/stake", {"tool": copies[0], "target": fx["wells"][0]}) 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")) 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 """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 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 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 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."""
Sell moon (shot of its asking state, when the moon can be sold). Shots of each."""
def rect_of(game, id_): def rect_of(game, id_):
@@ -49,9 +48,9 @@ def covers(outer, inner):
def run(game, args): def run(game, args):
fx = game.fixture fx = game.fixture
pre = (args[0] + "-") if args else "" pre = (args[0] + "-") if args else ""
sell = "confirm:sell-attachment" sell = "confirm:sell-tool"
game.idle() game.idle()
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"]) game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.idle() game.idle()
game.scroll_to(sell) game.scroll_to(sell)
before = rect_of(game, 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.expect(game.log_has(r"mm: toast Sold"), "Enter sells (a toast)")
game.shot(pre + "sellask-sold", settle=0.4) 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). # The moon's Sell moon (when it can be sold yet).
game.navigate("{section='moons', moon='%s'}" % fx["moon2"]) game.navigate("{section='moons', moon='%s'}" % fx["moon2"])
game.idle() game.idle()
+7 -2
View File
@@ -1,6 +1,6 @@
"""Narrow windows (run with --size 1024x640; needs --fixture): the aside gets its own row """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 under the moon list and the main panel and the page scrolls to it (the drill panel; a
panel); sections without the aside use the whole width; the banner wraps; keys scroll 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.""" the page. Args: an optional name prefix."""
@@ -18,6 +18,11 @@ def run(game, args):
game.click("well:" + well) game.click("well:" + well)
game.wait_for("view.well == '%s' and not busy" % well) game.wait_for("view.well == '%s' and not busy" % well)
game.idle() 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.wait_for("(g.scroll_pos.page or 0) > 100", timeout=5)
game.shot(pre + "small-well", settle=1.0) game.shot(pre + "small-well", settle=1.0)
# (keys scroll the area under the mouse: the moon itself scrolls when it doesn't fit, # (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 (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. confirm dialog (each cancelled with Esc, nothing bought); the theme toggle switches live.
Shots of each.""" Shots of each."""
@@ -44,9 +44,9 @@ def run(game, args):
fx = game.fixture fx = game.fixture
pre = (args[0] + "-") if args else "" pre = (args[0] + "-") if args else ""
game.idle() game.idle()
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"]) game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.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.navigate("{section='advisors'}")
game.idle() game.idle()
confirm(game, "confirm:buy-seat", pre + "texts-buy-seat") 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 """The well panel scrolled down (needs --fixture; it only shows, deploying is on the well
reach, drill lines with thumbnails and attachment tags, the drills heading's share note; 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.""" 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 # Generated by the art scripts
/art/equipment/build/ /art/equipment/build/
/art/advisors/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 map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
for every test view. for every test view.
- `config/game.json`: the game config (starting credits/resources/tools, `moonGrades`, - `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, 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 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; 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 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 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. 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 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). `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` `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 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 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. `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 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 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`. 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 Also selling tools for `equipmentResale.factor` (below 1) of their credit price (price +
credit price (a tool: price + its upgrades; refined parts not refunded): only idle its track levels; refined parts not refunded): only idle tools with nothing to claim,
tools with nothing to claim, never the last tool; attachments on it return to the inventory. never the last tool.
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on upgrade tiers), rules - `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 (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 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` batch is `maxBatch`, raised for the whole refinery (every slot) by `batchUpgrades`
(action `upgrade-batch`, `users.refinery_batch_level`). Tables (action `upgrade-batch`, `users.refinery_batch_level`). Tables
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`. `refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
- `bonus.go`: player bonuses. `bonusesFor` combines advisors and a tool's attachments - `bonus.go`: player bonuses. `bonusesFor` combines the seated advisors (multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
(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
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (used by
`stakeInTx`, the views and the sim); sells and refinery batches use `Bonus.Sell`/`Refine`. `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). Bonuses are fixed when the action runs (a stake keeps its duration and amount).
- `attachments.go`: attachments config (`attachments` in game.json: `slotsPerTool`, items - `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`,
with `speed`/`yield` multipliers), buy/equip/unequip rules (one of each per tool; equip `tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds
and unequip any time, they count from the next stake), store cards and the drill panel flat, with a price and, higher up, refined `parts`. Levels are per tool
(`drill=` in the URL, Equipment section, in the aside). Table `attachment` (`inventory_item.yield_level`, `cycle_level`; copies with the same levels are
(`inventory_item_id` null: unequipped). 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 - `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing 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 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>`, 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 `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, 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 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 tool Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`). locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
sections and actions need a gate there. 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 - `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 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`). 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; - `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in
the tools show their original images). `StoreItem.ImageAt` uses `<image>-tN` from use; the tools show their original images.
`web/static/assets/items` when it exists, else the base image.
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`. - `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 - `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 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 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. (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. 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. 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. - `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`. they write into `web/static/assets`.
## Commands ## 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) # 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 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 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 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 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 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 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 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, 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 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 (`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 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 ## Game config
`config/game.json` holds the balance numbers (starting credits, resources and tools, moon `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 price, well supply range, `wellCountWeights`: how many wells a new moon gets, resource prices
in credits per unit), the store items with their 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 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`: 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 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 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 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`. 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` Each drill's tracks are upgraded per tool, one level at a time, on its Equipment card or
and `speed`/`yield` multipliers) are bought in the Store and equipped on a drill from its in its panel, where idle drills can also be sold back for `equipmentResale.factor` of their
panel in Equipment, where idle drills and spare attachments can also be sold back for 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
`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
price × `factor`, at most `maxRatio` × the price of a replacement moon, no sooner than 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 `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 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` | 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/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/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. | | `/utils/liveops` | Dashboard, timed events and announcements. |
Actions (all `POST`, confirmed in the page's own dialog): 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 ### 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: 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 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 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 package main
// Advisors: people a player hires once, for credits, who boost the whole operation (drill // 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 // 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 // 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, // 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 // 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, // 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. // changes count from the next action: running drills keep their bonus.
// Tables advisor_hire (seat null: not seated) and users.advisor_seats. // Tables advisor_hire (seat null: not seated) and users.advisor_seats.
+12 -21
View File
@@ -137,12 +137,11 @@ type APITool struct {
Name string `json:"name"` Name string `json:"name"`
Code string `json:"code"` // model code, e.g. "PD-1" Code string `json:"code"` // model code, e.g. "PD-1"
Image string `json:"image"` // static URL Image string `json:"image"` // static URL
Tier int `json:"tier"` YieldLevel int `json:"yieldLevel"` // levels on its upgrade tracks (0: base)
MaxTier int `json:"maxTier"` CycleLevel int `json:"cycleLevel"`
Reach int `json:"reach"` // wells one cycle mines Reach int `json:"reach"` // wells one cycle mines
CycleSecs int `json:"cycleSecs"` // a stake now, with bonuses and live events CycleSecs float64 `json:"cycleSecs"` // a stake now, with bonuses and live events (tenths)
Yield float64 `json:"yield"` Yield float64 `json:"yield"`
Attachments []string `json:"attachments"`
StakeID string `json:"stakeId,omitempty"` // mining, if set StakeID string `json:"stakeId,omitempty"` // mining, if set
} }
@@ -156,7 +155,7 @@ type APIStake struct {
Reach int `json:"reach"` Reach int `json:"reach"`
Start int64 `json:"start"` Start int64 `json:"start"`
End int64 `json:"end"` End int64 `json:"end"`
Secs int `json:"durationSecs"` Secs float64 `json:"durationSecs"`
Ready bool `json:"ready"` Ready bool `json:"ready"`
Take float64 `json:"take"` // what claiming it would get now 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. // 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"` Gets *float64 `json:"gets,omitempty"`
Here *float64 `json:"here,omitempty"` Here *float64 `json:"here,omitempty"`
State string `json:"state,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 { type APIDrillGroup struct {
@@ -231,16 +230,16 @@ type APIToolGroup struct {
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked) ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
StoreItemID int `json:"storeItemId"` StoreItemID int `json:"storeItemId"`
Name string `json:"name"` Name string `json:"name"`
Tier int `json:"tier"` YieldLevel int `json:"yieldLevel"`
CycleLevel int `json:"cycleLevel"`
Count int `json:"count"` Count int `json:"count"`
CycleSecs int `json:"cycleSecs"` CycleSecs float64 `json:"cycleSecs"`
Yield float64 `json:"yield"` Yield float64 `json:"yield"`
Reach int `json:"reach"` Reach int `json:"reach"`
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
Free float64 `json:"free"` Free float64 `json:"free"`
Short bool `json:"short"` Short bool `json:"short"`
Blocked bool `json:"blocked"` // here is 0: the stake is refused Blocked bool `json:"blocked"` // here is 0: the stake is refused
Attachments []string `json:"attachments"`
} }
type APIBetterWell struct { type APIBetterWell struct {
@@ -334,14 +333,6 @@ func apiAmounts(rows []ResourceBalance) []APIAmount {
return out 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 { func apiStake(st StakeRow, projected bool) APIStake {
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource, 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(), 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 continue
} }
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name), 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), Image: itemImages + item.Image, YieldLevel: t.YieldLevel, CycleLevel: t.CycleLevel,
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))} Reach: item.ToolReach()}
tool.CycleSecs, tool.Yield = s.drill(t, item, "") tool.CycleSecs, tool.Yield = s.drill(t, item, "")
if st := s.StakeForTool(t.ID); st != nil { if st := s.StakeForTool(t.ID); st != nil {
tool.StakeID = st.ID 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{}} well := &APIWell{ID: w.ID, Resource: w.Resource, Rate: w.Rate, Idle: []APIToolGroup{}}
for _, g := range w.Idle { for _, g := range w.Idle {
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name, 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, YieldLevel: g.YieldLevel, CycleLevel: g.CycleLevel, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield,
Free: g.Free, Short: g.Short(), Blocked: g.Blocked(), Attachments: apiGear(g.Gear)}) Reach: max(1, g.Reach), Here: g.Here, Free: g.Free, Short: g.Short(), Blocked: g.Blocked()})
} }
if b := w.Better; b != nil { if b := w.Better; b != nil {
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free} 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 | | action | params | notes |
|---|---|---| |---|---|---|
| `buy-tool` | `id` (store item id) | response has `buyModal` | | `buy-tool` | `id` (store item id) | response has `buyModal` |
| `upgrade` | `tool` (tool id) | `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`) | | `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`) | | `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
| `collect` | `job` (job id), or none for every finished batch | | | `collect` | `job` (job id), or none for every finished batch | |
| `buy-slot` | – | refinery slot | | `buy-slot` | – | refinery slot |
@@ -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 | | `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=` | | `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) | | `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) | | `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` | | `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, tier, count, cycleSecs, yield, reach, here, free, short, blocked, attachments}], better: {moonId, wellId, resource, free, toolId}?}` | | `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`) | | `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 `""`) | | `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` | | `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) | | `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) | | `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%") | | `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"` | | `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, 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) | | `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=` | | `catalogVersion` | a hash of the catalog, for `catalog=` |
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`, 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`) ### equipment (`s=equipment`)
``` ```
{tools: [{id, storeItemId, name, code, image, tier, maxTier, count, idle, reach, cycleSecs, yield, {tools: [{id, storeItemId, name, code, image, yieldLevel, cycleLevel, count, idle, reach, cycleSecs, yield,
attachments, slots, freeSlots, selected, stake, next, preview, ladder, resale, noSale, bonus, gear}], selected, stake, tracks, resale, noSale, noSaleText, bonus}],
drill} drill}
``` ```
- `image`: the tool's art at its tier, like `state.tools`. - Copies of a tool (same type and track levels) are one row: `count` copies, `idle` of them
- Copies of a tool (same type, tier and attachments) are one row: `count` copies, `idle` idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill.
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`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}], next, ladder}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds
canAfford, blocked, short, blockedText, shortText}`; `blocked` is `credits` or `parts` per cycle); values are a stake's now, with the player's bonuses and live events. Upgrade
(`""` when it can be bought), `short` the credits missing; `blockedText` the button's with `tool=<id>&track=<kind>`.
tooltip ("Need ₥750 more", "Need 2 Dusk Alloy (you have 0). Make it in the Refinery"), - `next`: the next level, `null` at the top: `{level, value, label, price, parts: [{name,
`shortText` the line under it when credits are short. need, have}], canAfford, blocked, short, blockedText, shortText, rateFrom, rateTo, cost,
- `preview`: what the next tier changes, with the player's bonuses now: `{tier, yieldFrom, paybackSecs}`; `label` the track name and the level it buys ("Twin rotors 2"); `blocked`
yieldTo, secsFrom, secsTo, reachFrom, reachTo, rateFrom, rateTo, cost, paybackSecs}` is `credits` or `parts` (`""` when it can be bought), `short` the credits missing;
(`rate` is credits per hour; `paybackSecs` null if it never pays back). `blockedText` why the button is disabled ("Need ₥750 more", "Need 2 Dusk Alloy (you have
- `ladder`: every tier `[{tier, cycleSecs, yield, reach, rate, current, next}]`. 0). Make it in the Refinery"), `shortText` the line under it when credits are short;
- `resale`: what it sells for; `noSale`: `only-tool`, `claim-first`, `mining` or `""`, `rateFrom`/`rateTo` credits per hour now and after it, `cost` its price plus the parts at
`noSaleText` its text ("Claim its haul first"). sell value, `paybackSecs` null if it never pays back.
- `bonus`: `{speed, yield}` from advisors and its attachments (capped); `gear` the - `ladder`: every level from 0 `[{level, value, rate, price, parts: [{name, need, have}],
attachments `[{name, image, effect}]`. current, next}]` (the other track as it is; `price` 0 for level 0).
- `drill` (with `drill=<tool id>`, once `unlocked.equipment`, else `null`): the drill panel `{toolId, slots: [{n, - `resale`: what it sells for (its price and its levels' prices × `rules.equipmentResale`);
attachment}], spares: [{name, count, cycleSecs, yield, canEquip, why, whyText, price, resale, `noSale`: `only-tool`, `claim-first`, `mining` or `""`, `noSaleText` its text ("Claim its
confirm}], mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo, ladder, haul first").
gear}`. `attachment` is `""` for an empty slot (unequip with `item=<name>&to=<toolId>`); a - `bonus`: `{speed, yield}` from advisors (capped).
spare's `cycleSecs`/`yield` are the drill's with it equipped, `why` is `fitted` (one is on - `drill` (with `drill=<tool id>`, once `unlocked.equipment`, else `null`): the drill panel
already) or `full`; `confirm` (panel) is the sell-tool dialog, a spare's the `{toolId, mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo}`; its
sell-attachment one. `gear[].effectText` e.g. "+20% speed". While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy tracks and ladders are the selected row's (`selected`). `confirm` is the sell-tool dialog.
and `deployTo` its best well (a suggestion, as in `buyModal`). Its upgrade, slots, spares While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy and
and Sell drill are Store features (`upgrade`, `equip`, `unequip`, `sell-tool` are refused `deployTo` its best well (a suggestion, as in `buyModal`). Its upgrade buttons and Sell
before it): show them once `unlocked.store`; before that the panel is its stats and `ladder`. 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`) ### store (`s=store`)
``` ```
{tools: [{storeItemId, name, code, image, description, price, canAfford, owned, yield, cycleSecs, reach, rate, bestRate, whyText}], {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}]}
``` ```
`whyText`: the disabled Buy button's tooltip ("Need ₥1,400 more"), `""` when affordable. `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}`, 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. 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 `{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok":
attachments go back to your inventory.", "ok": "Sell for ₥750", "danger": true}`. Texts with "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`. 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): 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`). supply" (`over`).
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after - deploy card: "Only X left after current drills", "Well spoken for: nothing left after
current drills" (`well.idle[].short`, `here`, `free`). 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: The web's formats:
- credits: `₥` + the amount with thousands commas and up to 2 decimals (`₥1,234.5`); - 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. 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`. - banner time left: `2d 4h left`, `3h 20m left`, `45m left`.
- bonuses: `+` and the percent with one decimal at most (`+10%`, `+12.5%`). - bonuses: `+` and the percent with one decimal at most (`+10%`, `+12.5%`).
@@ -412,16 +410,16 @@ Content-Type: application/json
"past": [], "past": [],
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10}, "pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", "tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5, "image": "/static/assets/items/shovel.png", "yieldLevel": 0, "cycleLevel": 0,
"reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}], "reach": 1, "cycleSecs": 3, "yield": 4, "stakeId": "efc5071d-…"}],
"stakes": [{"id": "efc5071d-…", "moonId": "54daa782-…", "wellId": "b9d643c4-…", "toolId": "0fbfcb4f-…", "stakes": [{"id": "efc5071d-…", "moonId": "54daa782-…", "wellId": "b9d643c4-…", "toolId": "0fbfcb4f-…",
"resource": "Nebulance", "amount": 2, "reach": 1, "start": 1792391815259, "end": 1792391819259, "resource": "Nebulance", "amount": 2, "reach": 1, "start": 1792391815259, "end": 1792391819259,
"durationSecs": 4, "ready": false, "take": 2}], "durationSecs": 4, "ready": false, "take": 2}],
"moon": {"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "description": "A bleached mauve satellite …", "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, "rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70}, "finished": false, "ready": 0, "nextEnd": 1792391819259,
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47, "wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47,
"drills": [{"id": "efc5071d-…", "…": "…", "gets": 2, "here": 2, "state": "ok", "fate": "Prospector Drone PD-1 T0: +2"}], "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 T0", "wide": [], "ready": 0, "readyAmount": 0, "nextEnd": 1792391819259, "cap": 47, "label": "PD-1", "wide": [],
"selected": true, "redrill": 0, "demand": 2, "remain": 45, "dryAfter": 0, "dryAt": null}], "selected": true, "redrill": 0, "demand": 2, "remain": 45, "dryAfter": 0, "dryAt": null}],
"wide": null}, "wide": null},
"well": {"id": "b9d643c4-…", "resource": "Nebulance", "rate": 11.157, "idle": [], "better": null}, "well": {"id": "b9d643c4-…", "resource": "Nebulance", "rate": 11.157, "idle": [], "better": null},
@@ -431,8 +429,8 @@ Content-Type: application/json
"selected": "Azurium", "total": 0}, "selected": "Azurium", "total": 0},
"banner": [], "banner": [],
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [], "showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"}, "rules": {"wellSupplyMax": 90, "maxSupply": 90, "…": "…"},
"catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]}, "catalog": {"tools": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
"catalogVersion": "9adb4eabab929f5d" "catalogVersion": "9adb4eabab929f5d"
} }
} }
@@ -445,20 +443,20 @@ From a live server (long ids and arrays shortened).
`GET /api/state?s=equipment&drill=7e40…`, `state.equipment`: `GET /api/state?s=equipment&drill=7e40…`, `state.equipment`:
```json ```json
{"tools": [{"id": "7e40…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", {"tools": [{"id": "13b3…", "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, "image": "/static/assets/items/shovel.png", "yieldLevel": 1, "cycleLevel": 2, "count": 1, "idle": 1,
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true, "reach": 1, "cycleSecs": 2.2, "yield": 5, "selected": true, "stake": null,
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0}, "tracks": [{"kind": "yield", "name": "Twin rotors", "level": 1, "max": 4, "value": 5,
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1, "next": {"level": 2, "value": 6, "label": "Twin rotors 2", "price": 320, "parts": [],
"rateFrom": 24015.57, "rateTo": 32020.76, "cost": 900, "paybackSecs": 404.7}, "canAfford": true, "blocked": "", "short": 0, "blockedText": "", "shortText": "",
"ladder": [{"tier": 0, "cycleSecs": 3, "yield": 2, "reach": 1, "rate": 16010.38, "current": false, "next": false}, "…"], "rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7},
"resale": 1175, "noSale": "", "bonus": {"speed": 1.2, "yield": 1}, "ladder": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false},
"gear": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png", "effect": {"speed": 1.2, "yield": 1}}]}], {"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"]},
"drill": {"toolId": "7e40…", "slots": [{"n": 1, "attachment": "Turbo Core"}, {"n": 2, "attachment": ""}], {"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…", "ladder": ["…"]}],
"spares": [{"name": "Diamond Bit", "count": 1, "cycleSecs": 3, "yield": 2.3, "canEquip": true, "why": "", "resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}],
"price": 4000, "resale": 2000}], "drill": {"toolId": "13b3…", "mining": false, "resale": 980, "noSale": "", "noSaleText": "",
"mining": false, "resale": 750, "noSale": "", "idleSame": 0, "idleBest": "", "deployTo": null, "confirm": {"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok": "Sell for ₥980", "danger": true},
"ladder": ["…"], "gear": ["…"]}} "idleSame": 0, "idleBest": "", "deployTo": null}}
``` ```
`s=store`, `state.store`: `s=store`, `state.store`:
@@ -466,10 +464,7 @@ From a live server (long ids and arrays shortened).
```json ```json
{"tools": [{"storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", "image": "/static/assets/items/shovel.png", {"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, "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}, "…"], "owned": 1, "yield": 4, "cycleSecs": 3, "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}, "…"]}
``` ```
`s=refinery`, `state.refinery`: `s=refinery`, `state.refinery`:
+105 -195
View File
@@ -10,23 +10,20 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"math" "math"
"strings"
) )
// Image URLs, as the templates build them. // Image URLs, as the templates build them.
const ( const (
itemImages = "/static/assets/items/" itemImages = "/static/assets/items/"
attachmentImages = "/static/assets/attachments/"
advisorImages = "/static/assets/advisors/" advisorImages = "/static/assets/advisors/"
) )
// ------------------------------------------------------------------ always present // ------------------------------------------------------------------ always present
// APICatalog is the static game data: every tool, attachment, advisor, resource and // APICatalog is the static game data: every tool (with its upgrade tracks), advisor,
// refined item with its image and color. // resource and refined item with its image and color.
type APICatalog struct { type APICatalog struct {
Tools []APICatalogTool `json:"tools"` Tools []APICatalogTool `json:"tools"`
Attachments []APICatalogAttachment `json:"attachments"`
Advisors []APICatalogAdvisor `json:"advisors"` Advisors []APICatalogAdvisor `json:"advisors"`
Resources []APICatalogResource `json:"resources"` Resources []APICatalogResource `json:"resources"`
Refined []APICatalogRefined `json:"refined"` Refined []APICatalogRefined `json:"refined"`
@@ -35,7 +32,7 @@ type APICatalog struct {
// APIReachText is how the web words a tool's reach (wells one cycle mines): label on cards // 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", // ("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. // above the last entry's is the whole moon.
type APIReachText struct { type APIReachText struct {
Reach int `json:"reach"` Reach int `json:"reach"`
@@ -51,21 +48,25 @@ type APICatalogTool struct {
Image string `json:"image"` Image string `json:"image"`
Description string `json:"description"` Description string `json:"description"`
Price float64 `json:"price"` Price float64 `json:"price"`
Yield float64 `json:"yield"` // base, tier 0 Yield float64 `json:"yield"` // base (level 0)
CycleSecs int `json:"cycleSecs"` // base CycleSecs float64 `json:"cycleSecs"` // base
Reach int `json:"reach"` // tier 0 Reach int `json:"reach"`
Tiers []APITierUp `json:"tiers"` // upgrades, tier 1 on Tracks []APICatalogTrack `json:"tracks"` // yield, then cycle
} }
type APITierUp struct { // APICatalogTrack is one of a tool's upgrade tracks: its levels from 1, each setting the
Tier int `json:"tier"` // 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"` 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 Parts []APIAmount `json:"parts"` // refined items it uses up
Image string `json:"image"` // the tool's image at this tier
} }
type APIEffect struct { type APIEffect struct {
@@ -75,16 +76,6 @@ type APIEffect struct {
Refine float64 `json:"refine,omitempty"` 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 { type APICatalogAdvisor struct {
Name string `json:"name"` Name string `json:"name"`
Title string `json:"title"` Title string `json:"title"`
@@ -141,27 +132,23 @@ func apiNeed(need map[string]float64) []APIAmount {
func apiCatalog(s *State) *APICatalog { func apiCatalog(s *State) *APICatalog {
c := cfg() c := cfg()
cat := &APICatalog{Tools: []APICatalogTool{}, Attachments: []APICatalogAttachment{}, Advisors: []APICatalogAdvisor{}, cat := &APICatalog{Tools: []APICatalogTool{}, Advisors: []APICatalogAdvisor{},
Resources: []APICatalogResource{}, Refined: []APICatalogRefined{}, Reach: []APIReachText{}} Resources: []APICatalogResource{}, Refined: []APICatalogRefined{}, Reach: []APIReachText{}}
for i := range s.Config.StoreItems { for i := range s.Config.StoreItems {
item := &s.Config.StoreItems[i] item := &s.Config.StoreItems[i]
t := APICatalogTool{StoreItemID: item.ID, Name: item.Name, Code: toolCode(item.Name), Image: itemImages + item.Image, 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, Description: item.Description, Price: item.Price, Yield: item.ClaimAmount,
Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}} CycleSecs: float64(item.CompletionTimeInSecs), Reach: item.ToolReach(), Tracks: []APICatalogTrack{}}
for _, u := range item.Upgrades { for _, kind := range trackKinds {
t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost, tr := item.Track(kind)
Speed: max(1, u.Speed), CycleSecs: item.ToolSecs(u.Tier), ct := APICatalogTrack{Kind: kind, Name: tr.Name, Levels: []APITrackLevel{}}
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier))}) for n, l := range tr.Levels {
t.Tiers[len(t.Tiers)-1].Image = itemImages + item.ImageAt(u.Tier) 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) 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 { for i := range c.Advisors.Items {
a := &c.Advisors.Items[i] a := &c.Advisors.Items[i]
cat.Advisors = append(cat.Advisors, APICatalogAdvisor{Name: a.Name, Title: a.Title, Image: advisorImages + a.Image, 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 { type APIRules struct {
WellSupplyMax float64 `json:"wellSupplyMax"` // the default grade's biggest well (each moon has its maxSupply) 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 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"` WellsMin int `json:"wellsMin"`
WellsMax int `json:"wellsMax"` WellsMax int `json:"wellsMax"`
Pressure APIPressure `json:"pressure"` Pressure APIPressure `json:"pressure"`
@@ -218,7 +204,7 @@ type APIRules struct {
MoonResale MoonResale `json:"moonResale"` MoonResale MoonResale `json:"moonResale"`
EquipmentResale float64 `json:"equipmentResale"` // factor of the credit price EquipmentResale float64 `json:"equipmentResale"` // factor of the credit price
BonusCaps BonusCaps `json:"bonusCaps"` 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" 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() s, c := p.S, cfg()
b := s.Bonus() b := s.Bonus()
lo, hi := s.wellCountRange() 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, WellsMin: lo, WellsMax: hi,
Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale, Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale,
HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale}, HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale},
@@ -317,7 +303,7 @@ type APILiveEvent struct {
// ------------------------------------------------------------------ equipment // ------------------------------------------------------------------ equipment
type APIEquipment struct { 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> Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
} }
@@ -327,37 +313,38 @@ type APIToolRow struct {
Name string `json:"name"` Name string `json:"name"`
Code string `json:"code"` Code string `json:"code"`
Image string `json:"image"` Image string `json:"image"`
Tier int `json:"tier"` YieldLevel int `json:"yieldLevel"`
MaxTier int `json:"maxTier"` CycleLevel int `json:"cycleLevel"`
Count int `json:"count"` Count int `json:"count"`
Idle int `json:"idle"` Idle int `json:"idle"`
Reach int `json:"reach"` Reach int `json:"reach"`
CycleSecs int `json:"cycleSecs"` CycleSecs float64 `json:"cycleSecs"`
Yield float64 `json:"yield"` Yield float64 `json:"yield"`
Attachments []string `json:"attachments"`
Slots int `json:"slots"`
FreeSlots int `json:"freeSlots"`
Selected bool `json:"selected"` Selected bool `json:"selected"`
Stake *APIStake `json:"stake"` // the shown copy's drill Stake *APIStake `json:"stake"` // the shown copy's drill
Next *APINextTier `json:"next"` // null at the max tier Tracks []APITrack `json:"tracks"` // yield, then cycle
Preview *APIPreview `json:"preview"`
Ladder []APITierRow `json:"ladder"`
Resale float64 `json:"resale"` Resale float64 `json:"resale"`
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
NoSaleText string `json:"noSaleText"` NoSaleText string `json:"noSaleText"`
Bonus APIEffect `json:"bonus"` // advisors and its attachments, capped Bonus APIEffect `json:"bonus"` // advisors, capped
Gear []APIGearDetail `json:"gear"`
} }
type APIGearDetail struct { // 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"` Name string `json:"name"`
Image string `json:"image"` Level int `json:"level"`
Effect APIEffect `json:"effect"` Max int `json:"max"`
EffectText string `json:"effectText"` Value float64 `json:"value"`
Next *APINextLevel `json:"next"` // null at the top level
Ladder []APILadderStep `json:"ladder"`
} }
type APINextTier struct { type APINextLevel struct {
Tier int `json:"tier"` 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"` Price float64 `json:"price"`
Parts []APIPart `json:"parts"` Parts []APIPart `json:"parts"`
CanAfford bool `json:"canAfford"` // credits and parts CanAfford bool `json:"canAfford"` // credits and parts
@@ -367,6 +354,10 @@ type APINextTier struct {
// when credits are short ("Need ₥X more"). // when credits are short ("Need ₥X more").
BlockedText string `json:"blockedText"` BlockedText string `json:"blockedText"`
ShortText string `json:"shortText"` 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 { type APIPart struct {
@@ -375,34 +366,18 @@ type APIPart struct {
Have float64 `json:"have"` Have float64 `json:"have"`
} }
type APIPreview struct { type APILadderStep struct {
Tier int `json:"tier"` Level int `json:"level"`
YieldFrom float64 `json:"yieldFrom"` Value float64 `json:"value"`
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 Rate float64 `json:"rate"` // credits per hour
Price float64 `json:"price"` // 0 for level 0
Parts []APIPart `json:"parts"`
Current bool `json:"current"` Current bool `json:"current"`
Next bool `json:"next"` Next bool `json:"next"`
} }
type APIDrillPanel struct { type APIDrillPanel struct {
ToolID string `json:"toolId"` ToolID string `json:"toolId"`
Slots []APISlot `json:"slots"`
Spares []APISpare `json:"spares"`
Mining bool `json:"mining"` Mining bool `json:"mining"`
Resale float64 `json:"resale"` Resale float64 `json:"resale"`
NoSale string `json:"noSale"` NoSale string `json:"noSale"`
@@ -411,112 +386,67 @@ type APIDrillPanel struct {
IdleSame int `json:"idleSame"` // while it mines: idle tools of its type IdleSame int `json:"idleSame"` // while it mines: idle tools of its type
IdleBest string `json:"idleBest"` // the one pickTool would deploy IdleBest string `json:"idleBest"` // the one pickTool would deploy
DeployTo *APISuggestion `json:"deployTo"` // its best well DeployTo *APISuggestion `json:"deployTo"` // its best well
Ladder []APITierRow `json:"ladder"`
Gear []APIGearDetail `json:"gear"`
} }
type APISlot struct { func apiParts(rows []PartRow) []APIPart {
N int `json:"n"` out := []APIPart{}
Attachment string `json:"attachment"` // "" empty; unequip with item=<name>&to=<toolId> for _, p := range rows {
out = append(out, APIPart{Name: p.Name, Need: p.Need, Have: p.Have})
}
return out
} }
type APISpare struct { func apiTracks(s *State, rows []TrackRow) []APITrack {
Name string `json:"name"` out := []APITrack{}
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{}
for _, r := range rows { for _, r := range rows {
out = append(out, APITierRow{Tier: r.Tier, CycleSecs: r.Secs, Yield: r.Yield, Reach: r.Reach, Rate: r.Rate, t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value, Ladder: []APILadderStep{}}
Current: r.Current, Next: r.Next}) 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})
} }
return out 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),
func apiGearDetail(gear []*Attachment) []APIGearDetail { BlockedText: r.Missing, ShortText: r.Short, RateFrom: r.RateFrom, RateTo: r.RateTo, Cost: r.Cost}
out := []APIGearDetail{} for _, part := range r.Parts {
for _, a := range gear { if !part.OK() && next.Blocked == "" {
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
}
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 {
next.Blocked = "parts" next.Blocked = "parts"
} }
} }
if next.Short > 0 { if next.Short > 0 {
next.Blocked = "credits" next.Blocked = "credits"
} }
if gain := r.RateGain(); gain > 0 {
secs := r.Cost / gain * 3600
next.PaybackSecs = &secs
}
t.Next = next t.Next = next
} }
if u := row.Preview; u != nil { out = append(out, t)
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 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) e.Tools = append(e.Tools, t)
} }
if d := p.Drill; d != nil { if d := p.Drill; d != nil {
dp := &APIDrillPanel{ToolID: d.ID, Slots: []APISlot{}, Spares: []APISpare{}, Mining: d.Mining, Resale: d.Resale, dp := &APIDrillPanel{ToolID: d.ID, Mining: d.Mining, Resale: d.Resale,
NoSale: s.noSale(d.ToolRow), NoSaleText: d.NoSale, IdleSame: d.IdleSame, Ladder: apiTierRows(d.Ladder), NoSale: s.noSale(d.ToolRow), NoSaleText: d.NoSale, IdleSame: d.IdleSame,
Gear: apiGearDetail(d.Gear),
Confirm: APIConfirm{Title: "Sell drill", Text: d.SellConfirm(), OK: "Sell for " + formatCredits(d.Resale), Danger: true}} 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 { if d.IdleBest != nil {
dp.IdleBest = d.IdleBest.ID dp.IdleBest = d.IdleBest.ID
} }
@@ -535,7 +465,7 @@ func apiEquipment(p *Page) *APIEquipment {
type APIBuyModal struct { type APIBuyModal struct {
ToolID string `json:"toolId"` ToolID string `json:"toolId"`
StoreItemID int `json:"storeItemId"` StoreItemID int `json:"storeItemId"`
CycleSecs int `json:"cycleSecs"` CycleSecs float64 `json:"cycleSecs"`
Yield float64 `json:"yield"` Yield float64 `json:"yield"`
Reach int `json:"reach"` Reach int `json:"reach"`
NoMoons bool `json:"noMoons"` NoMoons bool `json:"noMoons"`
@@ -580,7 +510,6 @@ func apiBuyModal(m *BuyModal) *APIBuyModal {
type APIStore struct { type APIStore struct {
Tools []APIStoreTool `json:"tools"` Tools []APIStoreTool `json:"tools"`
Attachments []APIStoreGear `json:"attachments"`
} }
type APIStoreTool struct { type APIStoreTool struct {
@@ -593,27 +522,13 @@ type APIStoreTool struct {
CanAfford bool `json:"canAfford"` CanAfford bool `json:"canAfford"`
Owned int `json:"owned"` Owned int `json:"owned"`
Yield float64 `json:"yield"` // base, as the card shows Yield float64 `json:"yield"` // base, as the card shows
CycleSecs int `json:"cycleSecs"` // base CycleSecs float64 `json:"cycleSecs"` // base
Reach int `json:"reach"` Reach int `json:"reach"`
Rate float64 `json:"rate"` // credits per hour it would earn, with advisors Rate float64 `json:"rate"` // credits per hour it would earn, with advisors
BestRate float64 `json:"bestRate"` // the same for the player's best tool 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 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. // 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 { func whyShort(canAfford bool, balance, price float64) string {
if canAfford { if canAfford {
@@ -624,17 +539,12 @@ func whyShort(canAfford bool, balance, price float64) string {
func apiStore(p *Page) *APIStore { func apiStore(p *Page) *APIStore {
bal := p.S.Balance bal := p.S.Balance
st := &APIStore{Tools: []APIStoreTool{}, Attachments: []APIStoreGear{}} st := &APIStore{Tools: []APIStoreTool{}}
for _, r := range p.Store { for _, r := range p.Store {
st.Tools = append(st.Tools, APIStoreTool{StoreItemID: r.Item.ID, Name: r.Item.Name, Code: toolCode(r.Item.Name), 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, 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, Owned: r.Owned, Yield: r.Item.ClaimAmount, CycleSecs: float64(r.Item.CompletionTimeInSecs),
Reach: max(1, r.Item.ToolReach(0)), Rate: r.Rate, BestRate: r.BestRate, WhyText: whyShort(r.CanAfford, bal, r.Item.Price)}) Reach: r.Item.ToolReach(), 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)})
} }
return st return st
} }
@@ -911,7 +821,7 @@ func apiHelp(s *State) *APIHelp {
"Pick a well on your moon and deploy a tool on it.", "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.`, `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.", "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.", }, Note: "Your progress is saved on this device.",
NewGame: APIConfirm{Title: "Start over", Text: newGameConfirm, OK: "Start over", Danger: true}} 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)) t.Errorf("view %+v: %d stakes, %d tools", v, len(a.Stakes), len(a.Tools))
} }
for _, st := range a.Stakes { 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) 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) { if len(c.Reach) != len(c.Resources) {
fail("reach texts", c.Reach) fail("reach texts", c.Reach)
} }
for _, at := range c.Attachments { for _, tool := range c.Tools {
if at.EffectText == "" { if len(tool.Tracks) != 2 || tool.Tracks[0].Kind != "yield" || tool.Tracks[1].Kind != "cycle" {
fail("attachment effect", at) fail("tool tracks", tool)
} }
} }
} }
if e := a.Equipment; e != nil { if e := a.Equipment; e != nil {
for _, r := range e.Tools { 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) 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 := e.Drill; d != nil {
if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" { if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" {
fail("drill panel texts", d) 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 { 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) t.Errorf("view %+v: equipment %+v", v, a.Equipment)
} else if (a.Equipment.Drill != nil) != (v.Drill != "") { } else if (a.Equipment.Drill != nil) != (v.Drill != "") {
t.Errorf("view %+v: drill panel %v", v, a.Equipment.Drill != nil) 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": 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) t.Errorf("view %+v: store %+v", v, a.Store)
} }
case "refinery": 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 { 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, 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) t.Reach, t.Eff, t.CreditsPerHour, t.Payback, t.Ratio)
for _, u := range t.Tiers { for _, u := range t.Levels {
fmt.Printf(" tier %d %6.0f + parts %5.0f yield %5.0f wells %d on moon %5.0f %8.0f ₥/h payback %s\n", 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.Tier, u.Price, u.PartsValue, u.Yield, u.Reach, u.Eff, u.CreditsPerHour, u.Payback) u.Track, u.Level, u.Price, u.PartsValue, u.Yield, u.Secs, u.Eff, u.CreditsPerHour, u.Payback)
} }
} }
fmt.Println("\nrefinery") fmt.Println("\nrefinery")
+23 -71
View File
@@ -1,8 +1,7 @@
package main package main
// Player bonuses: drill attachments (attachments.go, equipped on one tool; their bonus is // Player bonuses: advisors (advisors.go, for the whole operation). Each bonus is a
// the modifier) and 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
// multiplier (1.1 = +10%). Within a player's attachments and advisors they multiply, then each kind is capped by bonusCaps
// in the config; live events (events.go) multiply on top, uncapped. Bonuses are applied // 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. // when an action runs: a stake fixes its duration and amount when it starts.
@@ -15,7 +14,7 @@ import (
"github.com/jackc/pgx/v5" "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 { type BonusCaps struct {
Speed float64 `json:"speed"` // drill speed: cycle duration is divided by it Speed float64 `json:"speed"` // drill speed: cycle duration is divided by it
Yield float64 `json:"yield"` // resources per stake Yield float64 `json:"yield"` // resources per stake
@@ -38,8 +37,8 @@ func mult(v float64) float64 {
return v return v
} }
// bonusesFor combines the named advisors and attachments, capped. // bonusesFor combines the named advisors, capped.
func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus { func (c *GameConfig) bonusesFor(advisors []string) Bonus {
b := noBonus b := noBonus
for _, name := range advisors { for _, name := range advisors {
if a := c.advisor(name); a != nil { if a := c.advisor(name); a != nil {
@@ -49,12 +48,6 @@ func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
b.Refine *= mult(a.Refine) 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.Speed = min(b.Speed, c.BonusCaps.Speed)
b.Yield = min(b.Yield, c.BonusCaps.Yield) b.Yield = min(b.Yield, c.BonusCaps.Yield)
b.Sell = min(b.Sell, c.BonusCaps.Sell) b.Sell = min(b.Sell, c.BonusCaps.Sell)
@@ -62,10 +55,10 @@ func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
return b return b
} }
// drillStats is a tool's cycle (seconds) and yield per stake with the player's bonus and // drillStats is a tool's cycle (seconds, to a tenth, at least 1) and yield per stake with
// the live event multipliers. stakeInTx and the views both use it. // 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) { func drillStats(secs, yield float64, b Bonus, eventSpeed, eventYield float64) (float64, float64) {
return max(1, int(math.Round(float64(secs)/(b.Speed*eventSpeed)))), yield * b.Yield * eventYield return max(1, roundSecs(secs/(b.Speed*eventSpeed))), yield * b.Yield * eventYield
} }
// pctText formats a multiplier as a bonus, e.g. 1.1 → "+10%". // 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 } 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 { for i, p := range cfg.Advisors.SeatPrices {
if p < 0 { if p < 0 {
return fmt.Errorf("advisors: seat %d price is negative", i+2) 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 { for _, a := range cfg.Advisors.Items {
if a.Name == "" || names[a.Name] { if a.Name == "" || names[a.Name] {
return fmt.Errorf("advisors: names must be set and unique (%q)", 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 // ------------------------------------------------------------------ in a transaction
// loadBonus reads the player's seated advisors and, with a tool id, the tool's attachments, and // loadBonus reads the player's seated advisors and combines them. The advisor rows are
// combines them. Callers that pass a tool have locked its row; the advisor rows are
// share-locked so a hire can't change them halfway through the action. // 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, ` 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) select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
if err != nil { if err != nil {
@@ -127,23 +106,12 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, er
if err != nil { if err != nil {
return noBonus, err return noBonus, err
} }
var mods []string return cfg().bonusesFor(advisors), nil
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
} }
// ------------------------------------------------------------------ state // ------------------------------------------------------------------ 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. // advisor seats into the state.
func loadBonusState(ctx context.Context, db querier, s *State) error { 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) 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) 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 nil
})
return err
} }
// Bonus is the player's advisor bonus (no attachments). // Bonus is the player's advisor bonus.
func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors, nil) } func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors) }
// 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])
}
// drill is what a stake of the tool on a well of the resource would take and return now. // 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) { func (s *State) drill(t Tool, item *StoreItem, resource string) (float64, float64) {
return drillStats(item.ToolSecs(t.Tier), item.ToolYield(t.Tier), s.toolBonus(t.ID), 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)) s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
} }
+7 -36
View File
@@ -1,6 +1,6 @@
package main 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 // 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 // (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 // the database (syncStoreItems), because inventory_item references store_item and the
@@ -31,7 +31,7 @@ type GameConfig struct {
MoonGrades []MoonGrade `json:"moonGrades"` MoonGrades []MoonGrade `json:"moonGrades"`
// Selling a moon back (resale.go). // Selling a moon back (resale.go).
MoonResale MoonResale `json:"moonResale"` MoonResale MoonResale `json:"moonResale"`
// Selling tools and attachments back (resale.go). // Selling tools back (resale.go).
EquipmentResale EquipmentResale `json:"equipmentResale"` EquipmentResale EquipmentResale `json:"equipmentResale"`
// Credits per unit sold, by resource name. // Credits per unit sold, by resource name.
ResourcePrices map[string]float64 `json:"resourcePrices"` ResourcePrices map[string]float64 `json:"resourcePrices"`
@@ -45,9 +45,8 @@ type GameConfig struct {
MoonNames MoonNames `json:"moonNames"` MoonNames MoonNames `json:"moonNames"`
// Crafting refined items from resources (refinery.go). // Crafting refined items from resources (refinery.go).
Refinery RefineryConfig `json:"refinery"` Refinery RefineryConfig `json:"refinery"`
// Drill attachments and advisors (bonus.go): their combined bonuses are capped here. // Advisors (bonus.go): their combined bonuses are capped here.
BonusCaps BonusCaps `json:"bonusCaps"` BonusCaps BonusCaps `json:"bonusCaps"`
Attachments AttachmentConfig `json:"attachments"`
Advisors AdvisorConfig `json:"advisors"` Advisors AdvisorConfig `json:"advisors"`
} }
@@ -177,23 +176,13 @@ func (cfg *GameConfig) validate() error {
if item.Reach < 0 || item.Reach > len(cfg.ResourcePrices) { 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)) return fmt.Errorf("store item %d: reach must be 0 to %d wells", item.ID, len(cfg.ResourcePrices))
} }
for _, u := range item.Upgrades { if err := cfg.validateTracks(item); err != nil {
if u.Price < 0 { return err
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 ids[item.ID] { if ids[item.ID] {
return fmt.Errorf("duplicate store item id %d", item.ID) return fmt.Errorf("duplicate store item id %d", item.ID)
} }
ids[item.ID] = true 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 { for _, id := range cfg.StartingTools {
if !ids[id] { if !ids[id] {
@@ -281,7 +270,8 @@ func (cfg *GameConfig) validateWells() error {
return nil 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 // that were removed from the file are deleted only if no player owns one (deleting
// would cascade to their tools). // would cascade to their tools).
func syncStoreItems(ctx context.Context, db *pgxpool.Pool) error { 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 { if err != nil {
return fmt.Errorf("store item %d: %w", item.ID, err) 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, ` _, err := tx.Exec(ctx, `
+124 -75
View File
@@ -32,115 +32,177 @@
"description": "A hovering scout drone that skims ore off the surface.", "description": "A hovering scout drone that skims ore off the surface.",
"image": "shovel.png", "image": "shovel.png",
"price": 1200, "price": 1200,
"claimAmount": 4, "claimAmount": 8,
"completionTimeInSecs": 3, "completionTimeInSecs": 3,
"upgrades": [ "tracks": {
{"tier": 1, "price": 80, "claimBoost": 0, "speed": 1.12}, "yield": {"name": "Twin rotors", "levels": [
{"tier": 2, "price": 100, "claimBoost": 0, "speed": 1.25}, {"value": 10, "price": 280},
{"tier": 3, "price": 80, "claimBoost": 0, "speed": 1.4, "parts": {"Dusk Alloy": 1}}, {"value": 12, "price": 280},
{"tier": 4, "price": 110, "claimBoost": 0, "speed": 1.6, "parts": {"Dusk Alloy": 1}}, {"value": 14, "price": 290, "parts": {"Dusk Alloy": 1}},
{"tier": 5, "price": 120, "claimBoost": 0, "speed": 1.8, "parts": {"Dusk Alloy": 2}} {"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, "id": 2,
"name": "Percussion Drill PX-4", "name": "Percussion Drill PX-4",
"description": "Hammers the crust a thousand times a second to crack open ore seams.", "description": "Hammers the crust a thousand times a second to crack open ore seams.",
"image": "pickaxe.png", "image": "pickaxe.png",
"price": 5700, "price": 4000,
"claimAmount": 20, "claimAmount": 60,
"completionTimeInSecs": 6, "completionTimeInSecs": 6,
"upgrades": [ "tracks": {
{"tier": 1, "price": 400, "claimBoost": 0, "speed": 1.12}, "yield": {"name": "Wider chisel", "levels": [
{"tier": 2, "price": 470, "claimBoost": 0, "speed": 1.25}, {"value": 72, "price": 760},
{"tier": 3, "price": 500, "claimBoost": 0, "speed": 1.4, "parts": {"Dusk Alloy": 2}}, {"value": 84, "price": 760},
{"tier": 4, "price": 610, "claimBoost": 0, "speed": 1.6, "parts": {"Cryo Cell": 1}}, {"value": 96, "price": 760, "parts": {"Dusk Alloy": 2}},
{"tier": 5, "price": 780, "claimBoost": 0, "speed": 1.8, "parts": {"Plasma Weave": 1}} {"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, "id": 3,
"name": "LunarLite L100", "name": "LunarLite L100",
"description": "An entry-level rotary drill: point it down and the ore rolls in.", "description": "An entry-level rotary drill: point it down and the ore rolls in.",
"image": "lunarlite.jpg", "image": "lunarlite.jpg",
"price": 27000, "price": 13400,
"claimAmount": 110, "claimAmount": 300,
"completionTimeInSecs": 8, "completionTimeInSecs": 8,
"upgrades": [ "tracks": {
{"tier": 1, "price": 1700, "claimBoost": 0, "speed": 1.12}, "yield": {"name": "Wide-bore bit", "levels": [
{"tier": 2, "price": 4500, "claimBoost": 0, "speed": 1.25}, {"value": 355, "price": 2300},
{"tier": 3, "price": 2900, "claimBoost": 0, "speed": 1.4, "parts": {"Cryo Cell": 1}}, {"value": 423, "price": 2900},
{"tier": 4, "price": 3400, "claimBoost": 0, "speed": 1.6, "parts": {"Plasma Weave": 2}}, {"value": 477, "price": 2300, "parts": {"Cryo Cell": 1}},
{"tier": 5, "price": 4300, "claimBoost": 0, "speed": 1.8, "parts": {"Plasma Weave": 3}} {"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, "id": 4,
"name": "LunarLite L300", "name": "LunarLite L300",
"description": "The L100's laser-cutting big brother, built to burn through bedrock.", "description": "The L100's laser-cutting big brother, built to burn through bedrock.",
"image": "lunarlite300.png", "image": "lunarlite300.png",
"price": 290000, "price": 129000,
"claimAmount": 1350, "claimAmount": 4000,
"completionTimeInSecs": 10, "completionTimeInSecs": 10,
"reach": 2, "reach": 2,
"upgrades": [ "tracks": {
{"tier": 1, "price": 37000, "claimBoost": 0, "speed": 1.12}, "yield": {"name": "Wider beam", "levels": [
{"tier": 2, "price": 23000, "claimBoost": 0, "speed": 1.25}, {"value": 4740, "price": 23000},
{"tier": 3, "price": 28000, "claimBoost": 0, "speed": 1.4, "parts": {"Cryo Cell": 2}}, {"value": 5630, "price": 27000},
{"tier": 4, "price": 34000, "claimBoost": 0, "speed": 1.6, "parts": {"Plasma Weave": 3}}, {"value": 6370, "price": 23000, "parts": {"Cryo Cell": 2}},
{"tier": 5, "price": 42000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 1}} {"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, "id": 5,
"name": "Lunar Regolith Extractor LRE-3000", "name": "Lunar Regolith Extractor LRE-3000",
"description": "An auger rig that chews through regolith by the ton and keeps every grain worth selling.", "description": "An auger rig that chews through regolith by the ton and keeps every grain worth selling.",
"image": "regolith.jpg", "image": "regolith.jpg",
"price": 3200000, "price": 1180000,
"claimAmount": 17000, "claimAmount": 48000,
"completionTimeInSecs": 12, "completionTimeInSecs": 12,
"reach": 3, "reach": 3,
"upgrades": [ "tracks": {
{"tier": 1, "price": 380000, "claimBoost": 0, "speed": 1.12}, "yield": {"name": "Fine sieves", "levels": [
{"tier": 2, "price": 230000, "claimBoost": 0, "speed": 1.25}, {"value": 56470, "price": 200000},
{"tier": 3, "price": 280000, "claimBoost": 0, "speed": 1.4, "parts": {"Plasma Weave": 3}}, {"value": 67760, "price": 260000},
{"tier": 4, "price": 740000, "claimBoost": 0, "speed": 1.6, "parts": {"Void Core": 1}}, {"value": 76240, "price": 200000, "parts": {"Plasma Weave": 3}},
{"tier": 5, "price": 510000, "claimBoost": 0, "speed": 1.8, "parts": {"Stellar Lattice": 1}} {"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, "id": 6,
"name": "Electrostatic Dust Excavator EDE-700", "name": "Electrostatic Dust Excavator EDE-700",
"description": "Charged plates pull ore-rich dust straight out of the ground, no digging needed.", "description": "Charged plates pull ore-rich dust straight out of the ground, no digging needed.",
"image": "dustexcavator.png", "image": "dustexcavator.png",
"price": 7400000, "price": 2120000,
"claimAmount": 36000, "claimAmount": 110000,
"completionTimeInSecs": 14, "completionTimeInSecs": 14,
"reach": 5, "reach": 5,
"upgrades": [ "tracks": {
{"tier": 1, "price": 470000, "claimBoost": 0, "speed": 1.12}, "yield": {"name": "Larger plates", "levels": [
{"tier": 2, "price": 1200000, "claimBoost": 0, "speed": 1.25}, {"value": 131390, "price": 390000},
{"tier": 3, "price": 820000, "claimBoost": 0, "speed": 1.4, "parts": {"Void Core": 1}}, {"value": 152780, "price": 390000},
{"tier": 4, "price": 1000000, "claimBoost": 0, "speed": 1.6, "parts": {"Stellar Lattice": 1}}, {"value": 177220, "price": 450000, "parts": {"Void Core": 1}},
{"tier": 5, "price": 1300000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 2, "Stellar Lattice": 2}} {"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, "id": 7,
"name": "Lunar Core Extractor LCE-360", "name": "Lunar Core Extractor LCE-360",
"description": "A deep-core rig that bores to the mantle and hauls up what's been buried for eons.", "description": "A deep-core rig that bores to the mantle and hauls up what's been buried for eons.",
"image": "lunarcoreextractor.png", "image": "lunarcoreextractor.png",
"price": 11000000, "price": 3100000,
"claimAmount": 47000, "claimAmount": 200000,
"completionTimeInSecs": 16, "completionTimeInSecs": 16,
"reach": 5, "reach": 5,
"upgrades": [ "tracks": {
{"tier": 1, "price": 840000, "claimBoost": 0, "speed": 1.12}, "yield": {"name": "Core barrel", "levels": [
{"tier": 2, "price": 1000000, "claimBoost": 0, "speed": 1.25}, {"value": 238300, "price": 560000},
{"tier": 3, "price": 1300000, "claimBoost": 0, "speed": 1.4, "parts": {"Void Core": 2}}, {"value": 280850, "price": 630000},
{"tier": 4, "price": 1600000, "claimBoost": 0, "speed": 1.6, "parts": {"Stellar Lattice": 2}}, {"value": 319150, "price": 560000, "parts": {"Void Core": 2}},
{"tier": 5, "price": 2200000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 3, "Stellar Lattice": 3}} {"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": [ "moons": [
@@ -225,19 +287,6 @@
] ]
}, },
"bonusCaps": {"speed": 1.6, "yield": 1.6, "sell": 1.15, "refine": 1.5}, "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": { "advisors": {
"seatPrices": [20000, 400000, 8000000], "seatPrices": [20000, 400000, 8000000],
"items": [ "items": [
+73 -197
View File
@@ -11,32 +11,16 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"maps" "maps"
"math" "math"
mrand "math/rand/v2" mrand "math/rand/v2"
"path"
"slices" "slices"
"strings"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "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 { type StoreItem struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -47,9 +31,10 @@ type StoreItem struct {
CompletionTimeInSecs int `json:"completionTimeInSecs"` CompletionTimeInSecs int `json:"completionTimeInSecs"`
// How many wells of a moon one cycle mines (0 or 1: the one it's deployed on). A tool // 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. // 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. // Read from cfg() like the tracks, not stored in store_item.
Reach int `json:"reach,omitempty"` Reach int `json:"reach,omitempty"`
Upgrades []Upgrade `json:"upgrades"` // 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 // 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 { type Tool struct {
ID string ID string
StoreItemID int 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. // Stake is an unclaimed staking event: a tool mining a well.
@@ -102,12 +88,12 @@ type Stake struct {
Resource string Resource string
Amount float64 Amount float64
Start time.Time Start time.Time
DurationInSecs int DurationInSecs float64
Reach int // wells of the moon it mines (StoreItem.Reach); WellID is the first Reach int // wells of the moon it mines (StoreItem.Reach); WellID is the first
} }
func (s Stake) End() time.Time { 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 { func (s Stake) Remaining(now time.Time) time.Duration {
@@ -144,11 +130,8 @@ type State struct {
YieldBoosts PriceBoosts // active live yield events YieldBoosts PriceBoosts // active live yield events
Pressure map[string]float64 // the player's selling pressure by item, now (market.go) 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), // Bonuses (bonus.go): seated advisors (their bonus counts), every advisor hired with
// seated advisors (their bonus counts), every advisor hired with their seat, advisor // their seat, advisor seats owned.
// seats owned.
Fitted map[string][]string
Spare map[string]int
Advisors []string Advisors []string
Hires []AdvisorHire // seated first, by seat Hires []AdvisorHire // seated first, by seat
AdvisorSeats int AdvisorSeats int
@@ -190,60 +173,6 @@ func (s *State) StakeForTool(toolID string) *Stake {
return nil 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 // ------------------------------------------------------------------ reads
func loadConfig(ctx context.Context, db *pgxpool.Pool) (Config, error) { 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 return cfg, err
} }
rows, err = db.Query(ctx, ` // Reach and the upgrade tracks aren't in the tables: they come from the game config.
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.
for i := range cfg.StoreItems { for i := range cfg.StoreItems {
item := &cfg.StoreItems[i] item := &cfg.StoreItems[i]
if c := configItem(item.ID); c != nil { if c := configItem(item.ID); c != nil {
item.Reach = c.Reach item.Reach, item.Tracks = c.Reach, c.Tracks
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
}
}
} }
} }
return cfg, err return cfg, err
@@ -317,19 +224,10 @@ func configItem(id int) *StoreItem {
return nil return nil
} }
func (item *StoreItem) upgrade(tier int) *Upgrade { // toolReach is how many wells a stake of the store item mines (game config).
for i := range item.Upgrades { func toolReach(storeItemID int) int {
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 {
if item := configItem(storeItemID); item != nil { if item := configItem(storeItemID); item != nil {
return item.ToolReach(tier) return item.ToolReach()
} }
return 1 return 1
} }
@@ -414,7 +312,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
} }
rows, err = db.Query(ctx, ` 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) where user_id = $1 order by store_item_id, created_at, id`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -951,51 +849,6 @@ func buyTool(ctx context.Context, db *pgxpool.Pool, userID string, storeItemID i
return toolID, err 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 // 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. // the page was stale), another idle tool of the same type goes instead.
func stake(ctx context.Context, db *pgxpool.Pool, userID, toolID, wellID string) error { func stake(ctx context.Context, db *pgxpool.Pool, userID, toolID, wellID string) error {
@@ -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, // pickTool locks the tool an action on toolID should use. Copies of a tool (same type and
// tier and attachments) are interchangeable, so when toolID is busy (an unclaimed stake) // track levels) are interchangeable, so when toolID is busy (an unclaimed stake) an idle
// an idle copy is used instead; with anyTier, any idle tool of the same type, the best // copy is used instead; with anyLevel, any idle tool of the same type, the best one first
// one first (a copy, then highest tier, most attachments). Without an idle one it's // (a copy, then the most levels). Without an idle one it's toolID itself, and the action
// toolID itself, and the action reports why it can't use it. // reports why it can't use it.
func pickTool(ctx context.Context, tx pgx.Tx, userID, toolID string, anyTier bool) (string, error) { func pickTool(ctx context.Context, tx pgx.Tx, userID, toolID string, anyLevel bool) (string, error) {
if !uuidPattern.MatchString(toolID) { if !uuidPattern.MatchString(toolID) {
return "", gameErr("Tool not found") return "", gameErr("Tool not found")
} }
rows, err := tx.Query(ctx, ` rows, err := tx.Query(ctx, `
with tool as ( with tool as (
select inventory_item.id, store_item_id, tier, select inventory_item.id, store_item_id, yield_level, cycle_level,
array(select item from attachment where inventory_item_id = inventory_item.id
order by item) as gear,
exists(select 1 from staking_event exists(select 1 from staking_event
where inventory_item_id = inventory_item.id where inventory_item_id = inventory_item.id
and not exists (select 1 from claim_event 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 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 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)) and ($3 or (tool.yield_level = me.yield_level and tool.cycle_level = me.cycle_level))
order by tool.id = me.id desc, (tool.tier = me.tier and tool.gear = me.gear) desc, order by tool.id = me.id desc,
tool.tier desc, cardinality(tool.gear) desc, tool.id`, userID, toolID, anyTier) (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 { if err != nil {
return "", err return "", err
} }
@@ -1117,23 +969,21 @@ func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) er
if err != nil { if err != nil {
return err return err
} }
var duration, storeItemID, tier int var storeItemID int
var amount float64 var lv Levels
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
select store_item.completion_time_in_secs, select store_item_id, yield_level, cycle_level from inventory_item
store_item.claim_amount + coalesce(upgrade_item.claim_boost, 0), where id = $1 and user_id = $2
store_item.id, inventory_item.tier for update`, toolID, userID).Scan(&storeItemID, &lv.Yield, &lv.Cycle)
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)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return gameErr("Tool not found") return gameErr("Tool not found")
} else if err != nil { } else if err != nil {
return err return err
} }
item := configItem(storeItemID)
if item == nil {
return gameErr("Tool not found")
}
// A tool is busy until its stake is claimed. // A tool is busy until its stake is claimed.
var busy bool 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: // 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). // 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 { if supply <= 0 && reach > 1 {
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
select id, supply, (select name from resource where id = resource_id) from resource_well 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 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. // 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 { if err != nil {
return err return err
} }
@@ -1195,10 +1045,7 @@ func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) er
if err != nil { if err != nil {
return err return err
} }
if item := configItem(storeItemID); item != nil { duration, amount := drillStats(item.ToolSecs(lv), item.ToolYield(lv), bonus, speed, yield)
duration = item.ToolSecs(tier)
}
duration, amount = drillStats(duration, amount, bonus, speed, yield)
// The drills already on the well (and multi-well ones reaching it) will take all it // The drills already on the well (and multi-well ones reaching it) will take all it
// has left: this one would get nothing. // 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 var n int
err := inTx(ctx, db, func(tx pgx.Tx) error { err := inTx(ctx, db, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, ` rows, err := tx.Query(ctx, `
select inventory_item.id, store_item.claim_amount from inventory_item select inventory_item.id, store_item_id, yield_level, cycle_level from inventory_item
join store_item on store_item.id = inventory_item.store_item_id
join lateral ( join lateral (
select staking_event.id, well_id from staking_event select staking_event.id, well_id from staking_event
where inventory_item_id = inventory_item.id where inventory_item_id = inventory_item.id
@@ -1366,15 +1212,45 @@ func redrill(ctx context.Context, db *pgxpool.Pool, userID, wellID string) (int,
} }
type idle struct { type idle struct {
ID string ID string
Amount float64 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 { if err != nil {
return err return err
} }
if len(tools) == 0 { if len(tools) == 0 {
return gameErr("No idle drills to redrill here") 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 var moonID string
if err := tx.QueryRow(ctx, "select source_id from resource_well where id = $1", wellID).Scan(&moonID); err != nil { if err := tx.QueryRow(ctx, "select source_id from resource_well where id = $1", wellID).Scan(&moonID); err != nil {
return err return err
@@ -1690,7 +1566,7 @@ func sell(ctx context.Context, db *pgxpool.Pool, userID, resource string, amount
if err != nil { if err != nil {
return err return err
} }
bonus, err := loadBonus(ctx, tx, userID, "") bonus, err := loadBonus(ctx, tx, userID)
if err != nil { if err != nil {
return err return err
} }
+73 -75
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 // 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 // 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 // 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 // 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 // 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. // ₥/click, and the summary compares the boosts with the copies.
@@ -15,7 +15,6 @@ import (
"math/rand/v2" "math/rand/v2"
"slices" "slices"
"sort" "sort"
"strings"
) )
type gsPlayer struct { type gsPlayer struct {
@@ -38,8 +37,8 @@ const (
) )
type gsTool struct { type gsTool struct {
item, tier int item int
mods []string lv Levels
doneAt int // busy until then; -1 idle doneAt int // busy until then; -1 idle
take []gsTake take []gsTake
} }
@@ -61,21 +60,20 @@ type gsMoon struct {
free float64 // ore not yet taken or booked 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 { type gsGroup struct {
item, tier int item int
mods []string lv Levels
n, tool int 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. // GSBuy is a candidate purchase: what it adds and what it costs.
type GSBuy struct { type GSBuy struct {
Kind, Name string Kind, Name string
Cost float64 // ₥ Cost float64 // ₥
Gain, Click float64 // ₥/hour added, ₥/click added 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 { func (b GSBuy) Payback() string {
@@ -162,7 +160,7 @@ func gradeStats(c *GameConfig) []GradeStat {
if st.Drill != "" { if st.Drill != "" {
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, st.Drill += fmt.Sprintf("%s (%.1f claims, %ds)", toolCode(item.Name), g.MeanSupply()/perWell,
item.CompletionTimeInSecs) 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) { func gsEval(c *GameConfig, stats []GradeStat, caps func(int) ReachCaps, grade int, gs []gsGroup, adv []string) (inc, clk float64) {
st := stats[grade] st := stats[grade]
stakes := 0.0 stakes := 0.0
cycles := map[int]bool{} cycles := map[float64]bool{}
b := c.bonusesFor(adv)
for _, x := range gs { for _, x := range gs {
if x.n == 0 { if x.n == 0 {
continue continue
} }
it := &c.StoreItems[x.item] it := &c.StoreItems[x.item]
b := c.bonusesFor(adv, x.mods) secs, y := drillStats(it.ToolSecs(x.lv), it.ToolYield(x.lv), b, 1, 1)
secs, y := drillStats(it.ToolSecs(x.tier), it.ToolYield(x.tier), b, 1, 1) reach := it.ToolReach()
reach := it.ToolReach(x.tier)
cycles[max(secs, gsClaimGap)] = true 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) n := float64(x.n)
inc += n * caps(grade).Yield(y, reach) * st.Unit * b.Sell * 3600 / p inc += n * caps(grade).Yield(y, reach) * st.Unit * b.Sell * 3600 / p
per := st.Units / max(1, st.Wells) / y 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 claims := 0.0
for secs := range cycles { 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)) clk = min(claims, 3600/gsClaimGap) + stakes + 3600/gsSellEvery*min(5, math.Round(st.Wells))
if budget := 3600 * gsClicksPerSec; clk > budget { if budget := 3600 * gsClicksPerSec; clk > budget {
@@ -250,30 +248,29 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
idx := map[string]int{} idx := map[string]int{}
var out []gsGroup var out []gsGroup
for j, t := range tools { 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 { if i, ok := idx[k]; ok {
out[i].n++ out[i].n++
continue continue
} }
idx[k] = len(out) 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 return out
} }
// with is gs with one drill of group k (none if k < 0) turned into (item, tier, mods). // with is gs with one drill of group k (none if k < 0) turned into (item, lv).
with := func(gs []gsGroup, k, item, tier int, mods []string) []gsGroup { with := func(gs []gsGroup, k, item int, lv Levels) []gsGroup {
out := slices.Clone(gs) out := slices.Clone(gs)
if k >= 0 { if k >= 0 {
out[k].n-- out[k].n--
} }
key := gsKey(item, tier, mods)
for i := range out { 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++ out[i].n++
return out 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) } eval := func(gs []gsGroup, adv []string) (float64, float64) { return gsEval(c, stats, caps, grade, gs, adv) }
partsCost := func(parts map[string]float64) (float64, bool) { partsCost := func(parts map[string]float64) (float64, bool) {
@@ -294,26 +291,21 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
var out []GSBuy var out []GSBuy
if len(tools) < gsMaxTools { if len(tools) < gsMaxTools {
for i, it := range c.StoreItems { 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 { for k, x := range gs {
it := &c.StoreItems[x.item] it := &c.StoreItems[x.item]
if u := it.NextUpgrade(x.tier); u != nil { 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 { 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, b := buy("upgrade", fmt.Sprintf("%s %s%d", toolCode(it.Name), kind[:1], n), u.Price+pc,
x.tool, with(gs, k, x.item, u.Tier, x.mods), advisors)) x.tool, with(gs, k, x.item, x.lv.With(kind, n)), advisors)
b.track = kind
out = append(out, b)
} }
} }
if len(x.mods) < c.Attachments.SlotsPerTool {
for _, m := range c.Attachments.Items {
if slices.Contains(x.mods, m.Name) {
continue
}
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))
}
} }
} }
if len(advisors) < seats || seats-1 < len(c.Advisors.SeatPrices) { if len(advisors) < seats || seats-1 < len(c.Advisors.SeatPrices) {
@@ -347,7 +339,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
sum := 0.0 sum := 0.0
for _, t := range tools { for _, t := range tools {
it := &c.StoreItems[t.item] 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 sum += 2 * y
} }
return sum 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 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 { book := func(t int, tl *gsTool, mi, only int) bool {
it := &c.StoreItems[tl.item] 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] m := moons[mi]
var order []int var order []int
if only >= 0 { if only >= 0 {
@@ -406,9 +398,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
slices.SortFunc(order, func(a, b int) int { 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) 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] tl.take = tl.take[:0]
for _, wi := range order { for _, wi := range order {
w := &m.wells[wi] w := &m.wells[wi]
@@ -423,7 +415,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
if len(tl.take) == 0 { if len(tl.take) == 0 {
return false return false
} }
tl.doneAt = t + secs tl.doneAt = t + int(math.Ceil(secs-1e-9))
return true return true
} }
// deploy stakes an idle tool on the moon with the most free ore. // 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. // Redeploy: the same well for a one-well drill, the same moon for the others.
if len(prev) > 0 { if len(prev) > 0 {
only := -1 only := -1
if c.StoreItems[tl.item].ToolReach(tl.tier) == 1 { if c.StoreItems[tl.item].ToolReach() == 1 {
only = prev[0].well only = prev[0].well
} }
book(t, tl, prev[0].moon, only) 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) pressure[name] = c.MarketPressure.Decay(pressure[name], 1)
} }
if t-lastSell >= gsSellEvery { if t-lastSell >= gsSellEvery {
sell := c.bonusesFor(advisors, nil).Sell sell := c.bonusesFor(advisors).Sell
tick := int64(t / 300) tick := int64(t / 300)
left := false left := false
for _, name := range resources { 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, 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)), 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} 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) row.Best[k] = best(k, true)
} }
res.Rows = append(res.Rows, row) res.Rows = append(res.Rows, row)
@@ -575,8 +567,8 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
break break
} }
cost := 1.0 cost := 1.0
if b.Kind == "attachment" || (b.Kind == "advisor" && len(advisors) >= seats) { if b.Kind == "advisor" && len(advisors) >= seats {
cost = 2 // buy and equip, buy a seat and hire cost = 2 // buy a seat and hire
} }
if bank < cost { if bank < cost {
wantBuy = true wantBuy = true
@@ -594,10 +586,8 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
case "copy": case "copy":
tools = append(tools, gsTool{item: b.tool, doneAt: -1}) tools = append(tools, gsTool{item: b.tool, doneAt: -1})
case "upgrade": case "upgrade":
tools[b.tool].tier++ lv := tools[b.tool].lv
case "attachment": tools[b.tool].lv = lv.With(b.track, lv.Of(b.track)+1)
name := b.Name[:strings.Index(b.Name, " on ")]
tools[b.tool].mods = append(slices.Clone(tools[b.tool].mods), name)
case "advisor": case "advisor":
if len(advisors) >= seats { if len(advisors) >= seats {
seats++ seats++
@@ -626,8 +616,8 @@ func printGradeSim(c *GameConfig) {
for _, pl := range gsPlayers { for _, pl := range gsPlayers {
r := simulatePlayer(c, pl, stats) r := simulatePlayer(c, pl, stats)
fmt.Printf("\n%s player\n", pl.Name) 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", 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 attachment", "best advisor", "buys") "best copy (/min, /clk)", "best upgrade", "best advisor", "buys")
cell := func(b GSBuy) string { cell := func(b GSBuy) string {
if b.Kind == "" { if b.Kind == "" {
return "-" return "-"
@@ -635,9 +625,9 @@ func printGradeSim(c *GameConfig) {
return fmt.Sprintf("%s %.3g %.3g", b.Name, b.PerMin(), b.PerClick()) return fmt.Sprintf("%s %.3g %.3g", b.Name, b.PerMin(), b.PerClick())
} }
for _, row := range r.Rows { 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"]), 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):") fmt.Print("first moon of each grade (clicks by then):")
for _, g := range c.MoonGrades { 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", 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) r.Idle, r.IdleAt, r.BuyGap, r.BuyGapAt, r.BuyGapPreS, r.Bought)
fmt.Print("purchases (count, ₥ spent, ₥/min and ₥/click added per 1k₥):") 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 { 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) 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 // 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 // with its upgrades: what each adds per 1k₥, in ₥/minute and ₥/click; upgrades as a ratio
// the copy. // to the copy: each track's first level, the whole track, and both tracks to the top.
func printBoostTable(c *GameConfig, stats []GradeStat) { func printBoostTable(c *GameConfig, stats []GradeStat) {
capsBy := map[int]ReachCaps{} capsBy := map[int]ReachCaps{}
caps := func(g int) ReachCaps { caps := func(g int) ReachCaps {
@@ -674,33 +664,41 @@ func printBoostTable(c *GameConfig, stats []GradeStat) {
} }
return capsBy[g] 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") 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",
for _, m := range c.Attachments.Items { "drill", "gr", "copy", "yield 1", "yield all", "cycle 1", "cycle all", "both all")
fmt.Printf(" %13s", m.Name)
}
fmt.Println()
for i := range c.StoreItems { for i := range c.StoreItems {
it := &c.StoreItems[i] it := &c.StoreItems[i]
g := c.gradeIndex(suitedGrade(c, it).Grade) g := c.gradeIndex(suitedGrade(c, it).Grade)
base := []gsGroup{{i, 0, nil, 1, 0}} inc0, clk0 := gsEval(c, stats, caps, g, []gsGroup{{i, Levels{}, 1, 0}}, nil)
inc0, clk0 := gsEval(c, stats, caps, g, base, nil)
per := func(gs []gsGroup, cost float64) (float64, float64) { per := func(gs []gsGroup, cost float64) (float64, float64) {
inc, clk := gsEval(c, stats, caps, g, gs, nil) inc, clk := gsEval(c, stats, caps, g, gs, nil)
return (inc - inc0) / 60 / cost * 1000, (inc/clk - inc0/clk0) / cost * 1000 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) fmt.Printf("%-9s %2s %6.3g %6.3g", toolCode(it.Name), c.MoonGrades[g].Grade, cm, cc)
cost, top := 0.0, 0 cost := func(kind string, upTo int) float64 {
for _, u := range it.Upgrades { sum := 0.0
cost += u.Price + partsValue(c, u.Parts) for n := 1; n <= upTo; n++ {
top = max(top, u.Tier) if l := it.TrackLevel(kind, n); l != nil {
sum += l.Price + partsValue(c, l.Parts)
} }
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 { return sum
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.Printf(" ×%5.2f ×%5.2f", m/cm, k/cc)
} }
fmt.Println() 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. // 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'" const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'buy_attachment', 'hire_advisor', 'buy_seat'"
// ------------------------------------------------------------------ dashboard // ------------------------------------------------------------------ dashboard
+4 -6
View File
@@ -7,13 +7,12 @@ package main
// //
// users.onboarding holds flags, which are only ever added: // users.onboarding holds flags, which are only ever added:
// - a gate passed: equipment (first deploy), market (first claim), store (first sale), // - a gate passed: equipment (first deploy), market (first claim), store (first sale),
// refinery (bought a moon of refinery.unlockGrade or higher), advisors (bought a tool or // refinery (bought a moon of refinery.unlockGrade or higher), advisors (bought a tool);
// attachment);
// - claimed:<resource>, the resources claimed so far; // - claimed:<resource>, the resources claimed so far;
// - seen:<tab>, the revealed tab was opened (no more shine); // - seen:<tab>, the revealed tab was opened (no more shine);
// - all: everything unlocked (players from before onboarding, admin "unlock all"). // - all: everything unlocked (players from before onboarding, admin "unlock all").
// Owning a moon is read from the game state. Redrill opens with the first claim. The store gate also reveals "+ New moon", // Owning a moon is read from the game state. Redrill opens with the first claim. The store gate also reveals "+ New moon",
// 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 // tools (the drill panel opens with Equipment and shows these parts once the Store is
// unlocked); Claim all also needs 2 tools. // 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. // actionGates is the feature each action needs; runAction refuses the rest.
var actionGates = map[string]string{ var actionGates = map[string]string{
"sell-moon": "store", "sell-tool": "store", "sell-attachment": "store", "sell-moon": "store", "sell-tool": "store",
"buy-tool": "store", "upgrade": "store", "buy-attachment": "store", "buy-tool": "store", "upgrade": "store", "redrill": "market", "new-game": "help",
"equip": "store", "unequip": "store", "redrill": "market", "new-game": "help",
"sell": "market", "sell": "market",
"refine": "refinery", "collect": "refinery", "buy-slot": "refinery", "upgrade-batch": "refinery", "refine": "refinery", "collect": "refinery", "buy-slot": "refinery", "upgrade-batch": "refinery",
"hire": "advisors", "buy-seat": "advisors", "seat-advisor": "advisors", "unseat-advisor": "advisors", "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 { 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, 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 } 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 { type PlayerTool struct {
Tool Tool
Item *StoreItem Item *StoreItem
MaxTier int Max Levels // the top level of each track
Stake *Stake Stake *Stake
MoonName string MoonName string
Remaining time.Duration Remaining time.Duration
@@ -291,7 +291,7 @@ func (s *server) loadPlayerPage(ctx context.Context, userID, adminID string) (*P
for _, t := range st.Tools { for _, t := range st.Tools {
pt := PlayerTool{Tool: t, Item: st.StoreItem(t.StoreItemID), Stake: st.StakeForTool(t.ID)} pt := PlayerTool{Tool: t, Item: st.StoreItem(t.StoreItemID), Stake: st.StakeForTool(t.ID)}
if pt.Item != nil { if pt.Item != nil {
pt.MaxTier = len(pt.Item.Upgrades) pt.Max = pt.Item.MaxLevels()
} }
if pt.Stake != nil { if pt.Stake != nil {
pt.MoonName = st.MoonName(pt.Stake.MoonID) pt.MoonName = st.MoonName(pt.Stake.MoonID)
@@ -402,10 +402,12 @@ func (s *server) handlePlayerAction(w http.ResponseWriter, r *http.Request) {
} }
case "tool": case "tool":
item, _ := strconv.Atoi(r.FormValue("item")) 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 var name string
if name, err = adminTool(ctx, s.db, adminID, id, item, tier); err == nil { if name, err = adminTool(ctx, s.db, adminID, id, item, lv); err == nil {
message = fmt.Sprintf("Granted %s (tier %d)", name, tier) message = fmt.Sprintf("Granted %s (yield level %d, cycle level %d)", name, lv.Yield, lv.Cycle)
} }
case "finish-stakes": case "finish-stakes":
var n int 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). // 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, tier int) (string, error) { func adminTool(ctx context.Context, db *pgxpool.Pool, adminID, userID string, itemID int, lv Levels) (string, error) {
var item *StoreItem var item *StoreItem
items := cfg().StoreItems items := cfg().StoreItems
for i := range items { for i := range items {
@@ -562,21 +564,21 @@ func adminTool(ctx context.Context, db *pgxpool.Pool, adminID, userID string, it
if item == nil { if item == nil {
return "", gameErr("Pick a tool") return "", gameErr("Pick a tool")
} }
if tier < 0 || tier > 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 tiers 0 to %d", item.Name, len(item.Upgrades)) 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 { err := inTx(ctx, db, func(tx pgx.Tx) error {
if _, err := lockPlayer(ctx, tx, userID); err != nil { if _, err := lockPlayer(ctx, tx, userID); err != nil {
return err return err
} }
_, err := tx.Exec(ctx, _, err := tx.Exec(ctx,
"insert into inventory_item(user_id, store_item_id, tier) values ($1, $2, $3)", "insert into inventory_item(user_id, store_item_id, yield_level, cycle_level) values ($1, $2, $3, $4)",
userID, itemID, tier) userID, itemID, lv.Yield, lv.Cycle)
if err != nil { if err != nil {
return err return err
} }
return logEvent(ctx, tx, userID, "admin_grant", 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 return item.Name, err
} }
+9 -26
View File
@@ -2,9 +2,9 @@ package main
// The refinery turns raw resources into refined items on a timer. A batch (recipe × // 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 // 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. // it. Refined items sell in the market and are parts for the higher levels of the drills'
// Recipes, prices, slot prices and upgrade parts come from config/game.json ("refinery", // upgrade tracks (tracks.go). Recipes, prices, slot prices and upgrade parts come from
// and "parts" on upgrade tiers). // config/game.json ("refinery", and "parts" on track levels).
// //
// Lock order, shared with game.go: users row (refinery actions) → bank_account → // Lock order, shared with game.go: users row (refinery actions) → bank_account →
// resource_account → refined_account. // resource_account → refined_account.
@@ -168,27 +168,15 @@ func (cfg *GameConfig) validateRefinery() error {
} }
} }
for _, si := range cfg.StoreItems { for _, si := range cfg.StoreItems {
for _, u := range si.Upgrades { for _, kind := range trackKinds {
for name, qty := range u.Parts { for i, l := range si.Track(kind).Levels {
for name, qty := range l.Parts {
if _, ok := tiers[name]; !ok || !(qty > 0) { 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 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)
} }
} }
} }
} }
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
}
}
}
} }
return nil return nil
} }
@@ -427,7 +415,7 @@ func startRefine(ctx context.Context, db *pgxpool.Pool, userID, name string, qty
return err return err
} }
// Advisors with a refinery bonus too (bonus.go). // Advisors with a refinery bonus too (bonus.go).
bonus, err := loadBonus(ctx, tx, userID, "") bonus, err := loadBonus(ctx, tx, userID)
if err != nil { if err != nil {
return err return err
} }
@@ -583,7 +571,7 @@ func sellRefined(ctx context.Context, db *pgxpool.Pool, userID, name string, amo
if err != nil { if err != nil {
return err return err
} }
bonus, err := loadBonus(ctx, tx, userID, "") bonus, err := loadBonus(ctx, tx, userID)
if err != nil { if err != nil {
return err return err
} }
@@ -687,11 +675,6 @@ func (s *State) partRows(need map[string]float64) []PartRow {
return rows 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 { func (s *State) refineryPage() *RefineryPage {
c := cfg() c := cfg()
maxBatch := c.Refinery.maxBatchAt(s.RefineryBatchLevel) 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 // ------------------------------------------------------------------ equipment
// Tools and attachments sell back for equipmentResale.factor (below 1) of their price in // Tools sell back for equipmentResale.factor (below 1) of their price in credits: the
// credits: a tool for its store price plus the upgrades it has had. Refined parts used by // store price plus the track levels it has had (tracks.go). Refined parts used by
// upgrades are not refunded. // upgrades are not refunded.
type EquipmentResale struct { type EquipmentResale struct {
Factor float64 `json:"factor"` Factor float64 `json:"factor"`
@@ -303,19 +303,11 @@ type EquipmentResale struct {
func resaleOf(price float64) float64 { return math.Floor(price * cfg().EquipmentResale.Factor) } 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. // ToolResale is what a tool sells for: its price and its upgrades' prices, × the factor.
func (item *StoreItem) ToolResale(tier int) float64 { func (item *StoreItem) ToolResale(lv Levels) float64 {
paid := item.Price return resaleOf(item.Price + item.upgradesPrice(lv))
for _, u := range item.Upgrades {
if u.Tier <= tier {
paid += u.Price
}
}
return resaleOf(paid)
} }
func (a *Attachment) Resale() float64 { return resaleOf(a.Price) } // sellTool sells one of the player's idle tools.
// sellTool sells one of the player's idle tools. Its attachments go back to the inventory.
func sellTool(ctx context.Context, db *pgxpool.Pool, userID, toolID string) (string, float64, error) { func sellTool(ctx context.Context, db *pgxpool.Pool, userID, toolID string) (string, float64, error) {
var name string var name string
var earned float64 var earned float64
@@ -329,17 +321,19 @@ func sellTool(ctx context.Context, db *pgxpool.Pool, userID, toolID string) (str
if err != nil { if err != nil {
return err return err
} }
var storeItemID, tier int var storeItemID int
var price, upgrades float64 var lv Levels
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
select inventory_item.store_item_id, inventory_item.tier, store_item.name, store_item.price, select inventory_item.store_item_id, inventory_item.yield_level, inventory_item.cycle_level, store_item.name
(select coalesce(sum(price), 0) from upgrade_item
where store_item_id = inventory_item.store_item_id and tier <= inventory_item.tier)
from inventory_item join store_item on store_item.id = inventory_item.store_item_id 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 { if err != nil {
return err return err
} }
item := configItem(storeItemID)
if item == nil {
return gameErr("That tool can't be sold")
}
var tools int var tools int
err = tx.QueryRow(ctx, "select count(*) from inventory_item where user_id = $1", userID).Scan(&tools) err = tx.QueryRow(ctx, "select count(*) from inventory_item where user_id = $1", userID).Scan(&tools)
if err != nil { 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") return gameErr("Claim this drill's haul first")
} }
earned = resaleOf(price + upgrades) earned = item.ToolResale(lv)
tag, err := tx.Exec(ctx, "update attachment set inventory_item_id = null where inventory_item_id = $1", toolID)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, "delete from inventory_item where id = $1", toolID); err != nil { if _, err := tx.Exec(ctx, "delete from inventory_item where id = $1", toolID); err != nil {
return err return err
} }
@@ -380,36 +370,7 @@ func sellTool(ctx context.Context, db *pgxpool.Pool, userID, toolID string) (str
return err return err
} }
return logEvent(ctx, tx, userID, "sell_tool", map[string]any{"tool": toolID, "item": storeItemID, 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 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) View View // the view to show next (the request's, unless the action moves on)
BoughtTool string // a tool just bought (the buy modal) BoughtTool string // a tool just bought (the buy modal)
Upgraded string // a tool just upgraded (its toast) Upgraded string // a tool just upgraded (its toast)
Track string // the track it upgraded (yield, cycle)
Finished []string // moons a claim mined out Finished []string // moons a claim mined out
SaleCredits float64 // what a market sale earned (the sell ticket's payout effect) 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. // 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) { func (s *server) runAction(ctx context.Context, userID, action string, get func(string) string) (actionResult, error) {
view := viewFromForm(get) view := viewFromForm(get)
var message, boughtTool, upgraded string var message, boughtTool, upgraded, track string
var finished []string var finished []string
var saleCredits float64 var saleCredits float64
var err error 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 { if name, earned, err = sellTool(ctx, s.db, userID, get("tool")); err == nil {
message = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned)) 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": case "buy-tool":
id, _ := strconv.Atoi(get("id")) id, _ := strconv.Atoi(get("id"))
if boughtTool, err = buyTool(ctx, s.db, userID, id); err == nil { 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": case "upgrade":
var tool string 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 message, upgraded = "Tool upgraded", tool
view.Drill = followTool(view.Drill, get("tool"), 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 { if n, err = upgradeRefineryBatch(ctx, s.db, userID); err == nil {
message = fmt.Sprintf("Refinery batches up to %d", n) 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": case "hire":
name := get("advisor") name := get("advisor")
if err = hireAdvisor(ctx, s.db, userID, name); err == nil { 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{}, err
} }
return actionResult{Action: action, Err: err, Message: message, View: view, BoughtTool: boughtTool, 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. // 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} toast := &Toast{Message: res.Message, Kind: res.Action, Finished: strings.Join(res.Finished, ","), Credits: res.SaleCredits}
if t := state.tool(res.Upgraded); t != nil { if t := state.tool(res.Upgraded); t != nil {
toast.Message = state.upgradedText(*t) toast.Message = state.upgradedText(*t, res.Track)
} }
return toast return toast
} }
+68 -80
View File
@@ -14,7 +14,8 @@ import (
const ( const (
// A bigger tool should earn at least this many times the previous one per hour. // A bigger tool should earn at least this many times the previous one per hour.
minRateStep = 1.2 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 maxUpgradePayback = 2.0
// Payback time shouldn't jump by more than this between consecutive tools. // Payback time shouldn't jump by more than this between consecutive tools.
maxPaybackJump = 4.0 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) return (r.sums[i] + amount*float64(n-i)) / float64(n)
} }
type TierStat struct { // LevelStat is a level of a tool's upgrade track, the other track at 0.
Tier int type LevelStat struct {
Track string // yield, cycle
Level int
Price float64 Price float64
PartsValue float64 // sell value of the refined parts it uses up PartsValue float64 // sell value of the refined parts it uses up
Yield float64 Yield float64
Secs float64
Reach int Reach int
Eff float64 // what a claim gets on average from a fresh moon (ReachCaps) Eff float64 // what a claim gets on average from a fresh moon (ReachCaps)
CreditsPerHour float64 // at Eff 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) TooBig bool // yield above what its reach can hold (Capacity)
Capacity float64 Capacity float64
} }
@@ -118,7 +122,7 @@ type ToolStat struct {
TooBig bool TooBig bool
RatioLow bool RatioLow bool
JumpHigh bool JumpHigh bool
Tiers []TierStat Levels []LevelStat // both tracks, yield first
} }
type Analysis struct { 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). // closest to 6.5 claims (each of its reach wells gets a share of a claim).
func suitedGrade(c *GameConfig, item *StoreItem) *MoonGrade { func suitedGrade(c *GameConfig, item *StoreItem) *MoonGrade {
best, bestD := &c.MoonGrades[0], math.Inf(1) 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 { for i := range c.MoonGrades {
g := &c.MoonGrades[i] g := &c.MoonGrades[i]
if d := math.Abs(math.Log(g.MeanSupply() / max(1e-9, perWell) / 6.5)); d < bestD { 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} return &StoreItem{ClaimAmount: 1}
} }
func perHour(units float64, secs int) float64 { func perHour(units, secs float64) float64 {
if secs <= 0 { if secs <= 0 {
return 0 return 0
} }
return units * 3600 / float64(secs) return units * 3600 / secs
} }
// Hours is a duration in hours; Inf for never. // 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 { for i, item := range items {
caps := reachCaps(c, suitedGrade(c, &item)) caps := reachCaps(c, suitedGrade(c, &item))
t := ToolStat{ID: item.ID, Name: item.Name, Price: item.Price, Claim: item.ClaimAmount, 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.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.CreditsPerHour = t.UnitsPerHour * a.AvgPrice
t.Payback = hours(item.Price, t.CreditsPerHour) t.Payback = hours(item.Price, t.CreditsPerHour)
maxWell := wellMax(item.ID) maxWell := wellMax(item.ID)
@@ -283,33 +287,34 @@ func analyze(c *GameConfig, errs []string, unsaved int) *Analysis {
t.JumpHigh = t.PaybackJump > maxPaybackJump t.JumpHigh = t.PaybackJump > maxPaybackJump
} }
} }
for _, u := range item.Upgrades { for _, kind := range trackKinds {
yield, reach := item.ClaimAmount+u.ClaimBoost, item.ToolReach(u.Tier)
eff := caps.Yield(yield, reach)
rate := perHour(eff, item.ToolSecs(u.Tier)) * a.AvgPrice
prevRate := t.CreditsPerHour prevRate := t.CreditsPerHour
if len(t.Tiers) > 0 { for n, u := range item.Track(kind).Levels {
prevRate = t.Tiers[len(t.Tiers)-1].CreditsPerHour 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) parts := partsValue(c, u.Parts)
t.Tiers = append(t.Tiers, TierStat{Tier: u.Tier, Price: u.Price, PartsValue: parts, Yield: yield, t.Levels = append(t.Levels, LevelStat{Track: kind, Level: n + 1, Price: u.Price, PartsValue: parts, Yield: yield,
Reach: reach, Eff: eff, CreditsPerHour: rate, Payback: hours(u.Price+parts, rate-prevRate), Secs: secs, Reach: reach, Eff: eff, CreditsPerHour: rate, Payback: hours(u.Price+parts, rate-prevRate),
Capacity: capacity(reach), TooBig: yield > capacity(reach)}) Capacity: capacity(reach), TooBig: yield > capacity(reach)})
prevRate = rate
}
} }
// An upgrade should be worth its price next to buying the next tool, and pay back // An upgrade should be worth its price next to buying the next tool, and pay back
// about as fast as the tool itself. // about as fast as the tool itself.
for _, u := range t.Tiers { for _, u := range t.Levels {
if i+1 < len(items) { if i+1 < len(items) {
next := &items[i+1] 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 { 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.", 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.Tier, formatCredits(u.Price+u.PartsValue), next.Name, formatCredits(next.Price))) item.Name, u.Track, u.Level, formatCredits(u.Price+u.PartsValue), next.Name, formatCredits(next.Price)))
} }
} }
if u.Payback > maxUpgradePayback*t.Payback { 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).", 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.Tier, u.Payback, maxUpgradePayback, t.Payback)) item.Name, u.Track, u.Level, u.Payback, maxUpgradePayback, t.Payback))
} }
} }
key := fmt.Sprintf("item:%d", item.ID) 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).", 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))) item.Name, formatMoney(item.ClaimAmount), reachText(t.Reach), formatMoney(t.Capacity)))
} else { } else {
for _, u := range t.Tiers { for _, u := range t.Levels {
if u.TooBig { if u.TooBig {
a.warn(key, fmt.Sprintf("%s at tier %d claims %s, more than %s can hold (%s).", a.warn(key, fmt.Sprintf("%s at yield level %d claims %s, more than %s can hold (%s).",
item.Name, u.Tier, formatMoney(u.Yield), reachText(u.Reach), formatMoney(u.Capacity))) item.Name, u.Level, formatMoney(u.Yield), reachText(u.Reach), formatMoney(u.Capacity)))
break break
} }
} }
@@ -398,7 +403,7 @@ type SimResult struct {
Hours int Hours int
Reaction int // seconds between the player's actions Reaction int // seconds between the player's actions
Tools []SimTool Tools []SimTool
Attachments []SimBuy // equipped, by kind Upgrades int // track levels bought
Advisors []SimBuy // hired Advisors []SimBuy // hired
Seats int // advisor seats at the end Seats int // advisor seats at the end
Moons []SimMoon Moons []SimMoon
@@ -409,7 +414,7 @@ type SimResult struct {
CappedAt SimTime 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 { type SimBuy struct {
Name string Name string
BoughtAt SimTime // -1 for never BoughtAt SimTime // -1 for never
@@ -418,8 +423,7 @@ type SimBuy struct {
type simTool struct { type simTool struct {
item int // index into the config's store items item int // index into the config's store items
tier int lv Levels
mods []string // attachments equipped
busy bool busy bool
doneAt int doneAt int
amount float64 amount float64
@@ -428,8 +432,8 @@ type simTool struct {
// simulate plays a greedy, active player: starting config, sells everything at the average // 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 // 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 // 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 // what breaks even soonest (time to save up plus payback): a tool, a track level for one
// attachment for one tool or an advisor (with a seat if needed), keeping enough for the next moon. // tool or an advisor (with a seat if needed), keeping enough for the next moon.
func simulate(c *GameConfig) *SimResult { func simulate(c *GameConfig) *SimResult {
price := avgPrice(c) price := avgPrice(c)
rng := rand.New(rand.NewPCG(simSeed[0], simSeed[1])) rng := rand.New(rand.NewPCG(simSeed[0], simSeed[1]))
@@ -440,29 +444,26 @@ func simulate(c *GameConfig) *SimResult {
itemIndex[item.ID] = i itemIndex[item.ID] = i
res.Tools = append(res.Tools, SimTool{Name: item.Name, Price: item.Price, AffordAt: -1, BoughtAt: -1}) 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 { for _, a := range c.Advisors.Items {
res.Advisors = append(res.Advisors, SimBuy{Name: a.Name, BoughtAt: -1}) res.Advisors = append(res.Advisors, SimBuy{Name: a.Name, BoughtAt: -1})
} }
var advisors []string // hired 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. // 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] it := &c.StoreItems[item]
secs, y := drillStats(it.ToolSecs(tier), it.ToolYield(tier), c.bonusesFor(adv, mods), 1, 1) secs, y := drillStats(it.ToolSecs(lv), it.ToolYield(lv), c.bonusesFor(adv), 1, 1)
return (secs + simReaction - 1) / simReaction * simReaction, y 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. // rate is a tool's credits per hour, with what its reach gets from a fresh moon.
capsBy := map[string]ReachCaps{} capsBy := map[string]ReachCaps{}
rate := func(item, tier int, mods, adv []string) float64 { rate := func(item int, lv Levels, adv []string) float64 {
secs, y := stats(item, tier, mods, adv) secs, y := stats(item, lv, adv)
g := suitedGrade(c, &c.StoreItems[item]) g := suitedGrade(c, &c.StoreItems[item])
if capsBy[g.Grade] == nil { if capsBy[g.Grade] == nil {
capsBy[g.Grade] = reachCaps(c, g) 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. // The grade of the next moon: the dearest one the player could buy twice.
nextGrade := func(credits float64) *MoonGrade { nextGrade := func(credits float64) *MoonGrade {
@@ -474,9 +475,9 @@ func simulate(c *GameConfig) *SimResult {
} }
return g return g
} }
reach := func(t *simTool) int { return c.StoreItems[t.item].ToolReach(t.tier) } reach := func(t *simTool) int { return c.StoreItems[t.item].ToolReach() }
yield := func(t *simTool) float64 { _, y := stats(t.item, t.tier, t.mods, advisors); return y } yield := func(t *simTool) float64 { _, y := stats(t.item, t.lv, advisors); return y }
cycle := func(t *simTool) int { secs, _ := stats(t.item, t.tier, t.mods, advisors); return secs } cycle := func(t *simTool) int { secs, _ := stats(t.item, t.lv, advisors); return secs }
credits := c.StartingCredits + c.StartingResources*price*float64(len(c.ResourcePrices)) credits := c.StartingCredits + c.StartingResources*price*float64(len(c.ResourcePrices))
earned, earnedAtSnap, snapAt, moonsAtSnap := 0.0, 0.0, 0, 0 earned, earnedAtSnap, snapAt, moonsAtSnap := 0.0, 0.0, 0, 0
@@ -493,7 +494,7 @@ func simulate(c *GameConfig) *SimResult {
income := func(adv []string) float64 { income := func(adv []string) float64 {
sum := 0.0 sum := 0.0
for i := range tools { 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 return sum
} }
@@ -526,7 +527,7 @@ func simulate(c *GameConfig) *SimResult {
act := t%simReaction == 0 act := t%simReaction == 0
// Claim finished drills (and sell at once, with the advisors' sell bonus and selling // Claim finished drills (and sell at once, with the advisors' sell bonus and selling
// pressure, spread evenly over the resources). // pressure, spread evenly over the resources).
sell := c.bonusesFor(advisors, nil).Sell sell := c.bonusesFor(advisors).Sell
if act { if act {
pressure = c.MarketPressure.Decay(pressure, simReaction) pressure = c.MarketPressure.Decay(pressure, simReaction)
units := 0.0 units := 0.0
@@ -599,17 +600,17 @@ func simulate(c *GameConfig) *SimResult {
event(t, "moon #%d (%s)", moons, formatCredits(moonPrice)) event(t, "moon #%d (%s)", moons, formatCredits(moonPrice))
continue continue
} }
// Soonest break-even (time to save up plus payback): a new tool; the next tier or // Soonest break-even (time to save up plus payback): a new tool; the next level of
// an attachment for one owned tool of each kind; or an advisor. // either track for one owned tool of each kind; or an advisor.
now := income(advisors) now := income(advisors)
breakEven := func(cost, gain float64) Hours { breakEven := func(cost, gain float64) Hours {
return hours(max(0, cost-credits), now) + hours(cost, gain) return hours(max(0, cost-credits), now) + hours(cost, gain)
} }
type choice struct { type choice struct {
kind string // tool, upgrade, attachment, advisor kind string // tool, upgrade, advisor
item int // store item index (tool) item int // store item index (tool)
tool int // index into tools (upgrade, attachment) tool int // index into tools (upgrade)
name string // attachment or advisor name string // advisor; upgrade: the track
seat float64 seat float64
cost float64 cost float64
payback Hours payback Hours
@@ -622,7 +623,7 @@ func simulate(c *GameConfig) *SimResult {
} }
if len(tools) < simMaxTools { if len(tools) < simMaxTools {
for i, item := range c.StoreItems { 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 { } else {
capped(t) capped(t)
@@ -630,27 +631,19 @@ func simulate(c *GameConfig) *SimResult {
seen := map[string]bool{} seen := map[string]bool{}
for i := range tools { for i := range tools {
tl := &tools[i] tl := &tools[i]
key := fmt.Sprint(tl.item, tl.tier, tl.mods) key := fmt.Sprint(tl.item, tl.lv)
if seen[key] { if seen[key] {
continue continue
} }
seen[key] = true seen[key] = true
item := &c.StoreItems[tl.item] item := &c.StoreItems[tl.item]
cur := rate(tl.item, tl.tier, tl.mods, advisors) cur := rate(tl.item, tl.lv, advisors)
if up := item.NextUpgrade(tl.tier); up != nil { for _, kind := range trackKinds {
consider(choice{kind: "upgrade", item: tl.item, tool: i, cost: up.Price + partsValue(c, up.Parts)}, n := tl.lv.Of(kind) + 1
rate(tl.item, tl.tier+1, tl.mods, advisors)-cur) 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)
} }
if len(tl.mods) >= c.Attachments.SlotsPerTool {
continue
}
for _, m := range c.Attachments.Items {
if slices.Contains(tl.mods, m.Name) {
continue
}
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 { for _, a := range c.Advisors.Items {
@@ -674,8 +667,11 @@ func simulate(c *GameConfig) *SimResult {
credits -= best.cost credits -= best.cost
switch best.kind { switch best.kind {
case "upgrade": case "upgrade":
tools[best.tool].tier++ lv := tools[best.tool].lv
event(t, "upgrade %s to tier %d (%s)", c.StoreItems[best.item].Name, tools[best.tool].tier, formatCredits(best.cost)) 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": case "tool":
tools = append(tools, simTool{item: best.item}) tools = append(tools, simTool{item: best.item})
if res.Tools[best.item].BoughtAt < 0 { if res.Tools[best.item].BoughtAt < 0 {
@@ -683,14 +679,6 @@ func simulate(c *GameConfig) *SimResult {
} }
res.Tools[best.item].Count++ res.Tools[best.item].Count++
event(t, "buy %s (%s)", c.StoreItems[best.item].Name, formatCredits(best.cost)) 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": case "advisor":
if best.seat > 0 { if best.seat > 0 {
res.Seats++ 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 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 ( create table staking_source (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
name varchar not null, name varchar not null,
@@ -66,9 +56,13 @@ create table resource_well (
create table inventory_item ( create table inventory_item (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
user_id uuid not null, user_id uuid not null,
tier integer not null default 0,
store_item_id int not null, store_item_id int not null,
created_at timestamp with time zone default now(), 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) constraint fk_user_inventory_item foreign key(user_id)
references users(id) on delete cascade, references users(id) on delete cascade,
constraint fk_store_item_inventory_item foreign key(store_item_id) constraint fk_store_item_inventory_item foreign key(store_item_id)
@@ -88,7 +82,7 @@ create table staking_event (
user_id uuid not null, user_id uuid not null,
well_id uuid not null, well_id uuid not null,
inventory_item_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, stake_amount float8 not null,
created_at timestamp with time zone default now(), created_at timestamp with time zone default now(),
-- Wells of the moon it mines (StoreItem.Reach), fixed at stake time; well_id is the -- 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) 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) -- Advisors hired (advisors.go), by config name. Seated ones (seat 1..users.advisor_seats)
-- give their bonus; seat null: hired but not seated. -- give their bonus; seat null: hired but not seated.
create table advisor_hire ( 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} {ID: 2, Kind: "admin_grant", Data: "{}", At: now}}, Page: 1, More: true}
stake := s.Stakes[0] stake := s.Stakes[0]
playerPage := &PlayerPage{Allowed: true, Self: true, P: player, State: s, Running: 1, Events: events, 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]}}, 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} StoreItems: s.Config.StoreItems, Grantable: []string{"Sollux"}, Flash: "Done", FlashError: true}
playersPage := &PlayersPage{Allowed: true, Query: "x", Sort: "seen", Players: []PlayerRow{player}, Total: 1} 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 { func newPlayerState() *State {
c := cfg() c := cfg()
s := &State{UserID: testID("u", 1), Config: Config{StoreItems: c.StoreItems}, Now: time.Now(), 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{}, Balance: c.StartingCredits, LastWell: map[string]string{}}
Spare: map[string]int{}}
for _, name := range sortedKeys(c.ResourcePrices) { for _, name := range sortedKeys(c.ResourcePrices) {
s.Config.Resources = append(s.Config.Resources, name) s.Config.Resources = append(s.Config.Resources, name)
s.Resources = append(s.Resources, ResourceBalance{Resource: 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), // 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. // live boosts and selling pressure.
func veteranState() *State { func veteranState() *State {
c := cfg() c := cfg()
@@ -239,7 +238,15 @@ func veteranState() *State {
for i := range c.StoreItems { for i := range c.StoreItems {
item := &c.StoreItems[i] item := &c.StoreItems[i]
for n := range 2 { 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. // 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) 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, 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, Resource: w.Resource, Amount: item.ToolYield(tool.Levels()), Start: start,
DurationInSecs: item.CompletionTimeInSecs, Reach: item.ToolReach(tool.Tier)}) DurationInSecs: item.ToolSecs(tool.Levels()), Reach: item.ToolReach()})
} }
// A crowded, over-drilled well: more drills than the wells table draws timer bars for, // A crowded, over-drilled well: more drills than the wells table draws timer bars for,
// the later ones short or getting nothing. // the later ones short or getting nothing.
@@ -265,7 +272,7 @@ func veteranState() *State {
s.Tools = append(s.Tools, tool) 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, 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), 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.LastWell[s.Tools[1].ID] = wells[1].ID
s.RefinerySlots = 2 s.RefinerySlots = 2
@@ -281,10 +288,6 @@ func veteranState() *State {
s.CycleBoosts = PriceBoosts{"all": 2} s.CycleBoosts = PriceBoosts{"all": 2}
s.YieldBoosts = PriceBoosts{first: 2} s.YieldBoosts = PriceBoosts{first: 2}
s.Pressure = map[string]float64{first: 50} 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. // Advisors: one seated, one hired but not seated, a free seat.
if len(c.Advisors.Items) > 1 { if len(c.Advisors.Items) > 1 {
s.Hires = []AdvisorHire{{Name: c.Advisors.Items[0].Name, Seat: 1}, {Name: c.Advisors.Items[1].Name}} 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 userId=:user_id
# A view: s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon, moon=<id>, well=<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, # 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 # 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) # most recent), adv=<advisor name> (advisors: a hired advisor's panel)
@@ -45,25 +45,20 @@ POST :host/act/sell-moon
:headers :headers
sell-moon=<moon id> 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 POST :host/act/sell-tool
:headers :headers
tool=<tool id> tool=<tool id>
# Sell one spare (unequipped) attachment
POST :host/act/sell-attachment
:headers
item=Turbo Core
# Buy a tool # Buy a tool
POST :host/act/buy-tool POST :host/act/buy-tool
:headers :headers
id=1 id=1
# Upgrade a tool # Upgrade a tool: one level on its yield or cycle track (track=yield|cycle)
POST :host/act/upgrade POST :host/act/upgrade
:headers :headers
tool=2bd5f846-ce1d-45be-998e-26d2e97b7f68 tool=2bd5f846-ce1d-45be-998e-26d2e97b7f68&track=yield
# Deploy a tool on a well # Deploy a tool on a well
POST :host/act/stake POST :host/act/stake
@@ -114,20 +109,6 @@ POST :host/act/buy-slot
POST :host/act/upgrade-batch POST :host/act/upgrade-batch
:headers :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 # Hire an advisor (config name) into a free seat
POST :host/act/hire POST :host/act/hire
:headers :headers
@@ -234,9 +215,8 @@ Content-Type: application/json
{"catalog": 0, "clientSeq": 2} {"catalog": 0, "clientSeq": 2}
# Other sections' actions (clientSeq is echoed back): buy-tool (id), upgrade (tool), # Other sections' actions (clientSeq is echoed back): buy-tool (id), upgrade (tool, track),
# sell-tool (tool), buy-attachment (item), equip/unequip (item, to), sell-attachment (item), # sell-tool (tool), refine (recipe, qty or all), collect (job or none), buy-slot, upgrade-batch, hire (advisor), buy-seat,
# 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), # seat-advisor (advisor, seat), unseat-advisor (advisor), swap-advisors (advisor, with),
# dismiss-advisor (advisor), sell-moon (sell-moon) # dismiss-advisor (advisor), sell-moon (sell-moon)
POST :host/api/act/buy-tool POST :host/api/act/buy-tool
@@ -332,10 +312,10 @@ POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/resources
:headers :headers
resource=Sollux&amount=500 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 POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/tool
:headers :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) # 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 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
}
+53 -156
View File
@@ -38,7 +38,14 @@ var templateFuncs = template.FuncMap{
// short is the tooltip on a button the player can't afford. // short is the tooltip on a button the player can't afford.
"short": func(balance, price float64) string { return shortBy(price - balance) }, "short": func(balance, price float64) string { return shortBy(price - balance) },
"mins": func(m int) string { return formatDuration(time.Duration(m) * time.Minute) }, "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, "curk": formatCompact,
"wells": wellsText, "wells": wellsText,
"dur": formatDuration, "dur": formatDuration,
@@ -125,7 +132,11 @@ func formatDuration(d time.Duration) string {
} }
// formatCycle formats a drill cycle in seconds: "3s", "45s", "2m", "2m 30s". // 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 { if secs < 60 {
return fmt.Sprintf("%ds", secs) 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, // A tool's panel opens with Equipment; what in it belongs to the Store (upgrades,
// attachments, selling) shows once the Store is unlocked (DrillPanel.Store). // selling) shows once the Store is unlocked (DrillPanel.Store).
if v.Section != "equipment" || s.tool(v.Drill) == nil || !s.Unlocked("equipment") { if v.Section != "equipment" || s.tool(v.Drill) == nil || !s.Unlocked("equipment") {
v.Drill = "" v.Drill = ""
} }
@@ -318,7 +329,6 @@ type Page struct {
Trade *MarketPage // market section: quotes, chart, portfolio (market.go) Trade *MarketPage // market section: quotes, chart, portfolio (market.go)
MaxSupply float64 // a full supply bar: the selected moon's grade (else the default grade) MaxSupply float64 // a full supply bar: the selected moon's grade (else the default grade)
Refinery *RefineryPage // refinery section (refinery.go) Refinery *RefineryPage // refinery section (refinery.go)
Gear []AttachmentCard // store section (attachments.go)
Drill *DrillPanel // equipment section, with a tool selected Drill *DrillPanel // equipment section, with a tool selected
Advisors *AdvisorsPage // advisors section (advisors.go) Advisors *AdvisorsPage // advisors section (advisors.go)
Ranking *LeaderboardPage // leaderboard section (leaderboard.go), loaded by the server Ranking *LeaderboardPage // leaderboard section (leaderboard.go), loaded by the server
@@ -490,8 +500,7 @@ type WellDetail struct {
type StakeRow struct { type StakeRow struct {
Stake Stake
Item *StoreItem Item *StoreItem
Tier int Levels Levels // the tool's track levels
Gear []*Attachment
Ready bool Ready bool
Remaining time.Duration Remaining time.Duration
Take float64 // what claiming it would get now, from what its wells hold 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). // ReachText says how many wells it mines ("" for one).
func (r StakeRow) ReachText() string { return reachLabel(r.Reach) } 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 // ToolGroup is tools of the same type and track levels. Secs and Yield are what a stake
// stake would take and return now, with the player's bonuses and live events. // would take and return now, with the player's bonuses and live events.
type ToolGroup struct { type ToolGroup struct {
Tool Tool
Item *StoreItem Item *StoreItem
Gear []*Attachment
Count int Count int
Secs int Secs float64
Yield float64 Yield float64
Reach int // wells of a moon one cycle mines 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) 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 { type ToolRow struct {
ToolGroup ToolGroup
FreeSlots int // attachment slots left
Selected bool Selected bool
Idle int // copies (Count) not mining Idle int // copies (Count) not mining
MaxTier int
Next *Upgrade
CanAfford bool
Stake *StakeRow Stake *StakeRow
Parts []PartRow // refined items the next upgrade uses (refinery.go) Tracks []TrackRow // its yield and cycle upgrade tracks (tracks.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
} }
type StoreRow struct { type StoreRow struct {
@@ -576,7 +576,7 @@ type StoreRow struct {
func (r StoreRow) RateGain() float64 { return r.Rate - r.BestRate } func (r StoreRow) RateGain() float64 { return r.Rate - r.BestRate }
// ReachText says how many wells the tool mines ("" for one). // 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 { type MarketRow struct {
ResourceBalance 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 := StakeRow{Stake: st, Remaining: st.Remaining(s.Now), Take: st.Amount}
row.Ready = row.Remaining == 0 row.Ready = row.Remaining == 0
if t := s.tool(st.ToolID); t != nil { 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 { if m := s.moon(st.MoonID); m != nil {
supply := m.supply() supply := m.supply()
@@ -738,12 +738,12 @@ func (s *State) wideGroup(m *Moon) *DrillGroup {
return g 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). // 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 { func (s *State) toolGroups(idleOnly bool, resource string) []ToolGroup {
var groups []ToolGroup var groups []ToolGroup
key := func(t Tool) string { 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{} keys := map[string]int{}
for _, t := range s.Tools { for _, t := range s.Tools {
@@ -759,7 +759,7 @@ func (s *State) toolGroups(idleOnly bool, resource string) []ToolGroup {
continue continue
} }
keys[key(t)] = len(groups) 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) g.Secs, g.Yield = s.drill(t, item, resource)
groups = append(groups, g) groups = append(groups, g)
} }
@@ -881,30 +881,11 @@ func (s *State) page(v View) *Page {
continue continue
} }
row := ToolRow{ 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, Selected: t.ID == v.Drill,
MaxTier: len(item.Upgrades),
Next: item.NextUpgrade(t.Tier),
} }
row.Secs, row.Yield = s.drill(t, item, "") row.Secs, row.Yield = s.drill(t, item, "")
row.FreeSlots = max(0, cfg().Attachments.SlotsPerTool-len(row.Gear)) row.Tracks = s.trackRows(t, item)
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))
}
}
}
if st := s.StakeForTool(t.ID); st != nil { if st := s.StakeForTool(t.ID); st != nil {
sr := s.stakeRow(*st) sr := s.stakeRow(*st)
row.Stake = &sr row.Stake = &sr
@@ -926,14 +907,12 @@ func (s *State) page(v View) *Page {
} }
case "store": case "store":
p.Gear = s.attachmentCards()
best := s.bestToolRate() best := s.bestToolRate()
for i := range s.Config.StoreItems { for i := range s.Config.StoreItems {
item := &s.Config.StoreItems[i] item := &s.Config.StoreItems[i]
row := StoreRow{Item: item, CanAfford: s.Balance >= item.Price, BestRate: best} row := StoreRow{Item: item, CanAfford: s.Balance >= item.Price, BestRate: best}
secs, yield := drillStats(item.CompletionTimeInSecs, item.ToolYield(0), s.Bonus(), secs, yield := s.drillAt(item, Levels{}, "")
s.CycleBoosts.For("all"), s.YieldBoosts.For("")) row.Rate = s.creditsPerHour(yield, secs, item.ToolReach())
row.Rate = s.creditsPerHour(yield, secs, item.ToolReach(0))
for _, t := range s.Tools { for _, t := range s.Tools {
if t.StoreItemID == item.ID { if t.StoreItemID == item.ID {
row.Owned++ row.Owned++
@@ -970,22 +949,9 @@ func (s *State) page(v View) *Page {
// Busy is how many of the row's copies are mining. // Busy is how many of the row's copies are mining.
func (r ToolRow) Busy() int { return r.Count - r.Idle } func (r ToolRow) Busy() int { return r.Count - r.Idle }
// Pips is one entry per attachment slot, true where one is equipped. // copyKey is the same for copies of a tool: same type and track levels.
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.
func (s *State) copyKey(t Tool) string { func (s *State) copyKey(t Tool) string {
var names []string return fmt.Sprint(t.StoreItemID, t.Levels())
for _, g := range s.gear(t.ID) {
names = append(names, g.Name)
}
return fmt.Sprint(t.StoreItemID, t.Tier, names)
} }
// idleCopy is an idle copy of a busy tool (the tool itself if it's idle or has none), so // 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 { type BuyModal struct {
ToolID string ToolID string
Item *StoreItem Item *StoreItem
Secs int Secs float64
Yield float64 Yield float64
Reach int Reach int
Suggestions []Suggestion Suggestions []Suggestion
@@ -1087,9 +1053,9 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
if item == nil { if item == nil {
return 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, "") m.Secs, m.Yield = s.drill(*t, item, "")
cycle := float64(m.Secs) / 60 cycle := m.Secs / 60
var all []Suggestion var all []Suggestion
for i := range s.Moons { for i := range s.Moons {
moon := &s.Moons[i] 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). // ReachText says how many wells the tool mines ("" for one).
func (m *BuyModal) ReachText() string { return reachLabel(m.Reach) } 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). // bestToolRate is what the player's best-earning tool makes per hour (creditsPerHour).
func (s *State) bestToolRate() float64 { func (s *State) bestToolRate() float64 {
best := 0.0 best := 0.0
for _, t := range s.Tools { for _, t := range s.Tools {
if item := s.StoreItem(t.StoreItemID); item != nil { if item := s.StoreItem(t.StoreItemID); item != nil {
secs, yield, reach := s.tierStats(t, item, t.Tier) secs, yield := s.drill(t, item, "")
best = max(best, s.creditsPerHour(yield, secs, reach)) best = max(best, s.creditsPerHour(yield, secs, item.ToolReach()))
} }
} }
return best return best
@@ -1245,7 +1131,7 @@ func (s *State) bestToolRate() float64 {
// default grade (State.MoonGrade), deployed on its // default grade (State.MoonGrade), deployed on its
// richest well (what its reach can hold, as the economy page's sim counts it), sold at // 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). // 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() c := cfg()
if len(c.ResourcePrices) == 0 { if len(c.ResourcePrices) == 0 {
return 0 return 0
@@ -1295,7 +1181,7 @@ func formatCompact(n float64) string {
func formatSpan(secs float64) string { func formatSpan(secs float64) string {
n := int(math.Ceil(secs)) n := int(math.Ceil(secs))
if n < 3600 { if n < 3600 {
return formatCycle(n) return formatCycle(float64(n))
} }
h, m := n/3600, n%3600/60 h, m := n/3600, n%3600/60
if m == 0 { if m == 0 {
@@ -1498,7 +1384,7 @@ func (r StakeRow) Pct() float64 {
if r.Ready || r.DurationInSecs <= 0 { if r.Ready || r.DurationInSecs <= 0 {
return 100 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"). // 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 { if r.Item != nil {
name = r.Item.Name name = r.Item.Name
} }
s := fmt.Sprintf("%s T%d", name, r.Tier) s := name + r.Levels.Tag()
if r.Reach > 1 { if r.Reach > 1 {
s += " (" + reachLabel(r.Reach) + ")" s += " (" + reachLabel(r.Reach) + ")"
} }
@@ -1548,7 +1434,7 @@ func (r StakeRow) Fate() string {
func (r WellRow) DrillsTitle() string { return drillsTitle(r.Queue()) } func (r WellRow) DrillsTitle() string { return drillsTitle(r.Queue()) }
func (g DrillGroup) DrillsTitle() string { return drillsTitle(g.Drills) } 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. // a well's multi-well drills show only as diamond-capped bars.
func (r WellRow) DrillLabel() string { return drillLabel(r.Drills) } func (r WellRow) DrillLabel() string { return drillLabel(r.Drills) }
func (g DrillGroup) DrillLabel() string { return drillLabel(g.Drills) } func (g DrillGroup) DrillLabel() string { return drillLabel(g.Drills) }
@@ -1566,7 +1452,7 @@ func drillLabel(q []StakeRow) string {
return "" return ""
} }
if len(q) == 1 { 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{} count := map[string]int{}
top := q[0].Code() top := q[0].Code()
@@ -1647,3 +1533,14 @@ type SupplyBar struct {
func (r WellRow) Bar(max float64, now time.Time, panel bool) SupplyBar { func (r WellRow) Bar(max float64, now time.Time, panel bool) SupplyBar {
return SupplyBar{W: r, Max: max, Now: now, Panel: panel} 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" "testing"
) )
// A tool's panel opens as soon as Equipment is unlocked; its Store parts (upgrade, // A tool's panel opens as soon as Equipment is unlocked; its Store parts (upgrades,
// attachments, selling) show only once the Store is. // selling) show only once the Store is.
func TestViewDrillPanelOnboarding(t *testing.T) { func TestViewDrillPanelOnboarding(t *testing.T) {
tmpl, err := (&server{}).parseTemplates() tmpl, err := (&server{}).parseTemplates()
if err != nil { if err != nil {
@@ -44,10 +44,10 @@ func TestViewDrillPanelOnboarding(t *testing.T) {
t.Fatalf("render: %v", err) t.Fatalf("render: %v", err)
} }
html := b.String() html := b.String()
if !strings.Contains(html, "<h3>Tiers</h3>") { if strings.Count(html, "track-ladder") != 2 {
t.Errorf("%v: no tier ladder", c.flags) 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 { if strings.Contains(html, part) != c.store {
t.Errorf("%v: %q shown %v, want %v", c.flags, part, !c.store, 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. // Cycles: 3s, 2m, 2m 30s.
export function secs(n) { 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); n = Math.round(n);
if (n < 60) return `${n}s`; if (n < 60) return `${n}s`;
return n % 60 === 0 ? `${n / 60}m` : `${Math.floor(n / 60)}m ${n % 60}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"). // The tool's model code, the last word of its name ("PD-1").
export const code = (name) => name?.trim().split(/\s+/).pop() || "Tool"; 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) { export function drillLabel(drills, toolOf) {
if (!drills.length) return ""; if (!drills.length) return "";
const codeOf = (d) => toolOf(d.toolId)?.code ?? "Tool"; 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(); const count = new Map();
let top = codeOf(drills[0]); let top = codeOf(drills[0]);
for (const d of drills) { for (const d of drills) {
+6 -5
View File
@@ -70,16 +70,17 @@ function addItem(st, name, delta) {
// ---------------------------------------------------------------- tools and stakes // ---------------------------------------------------------------- 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) { function idleCopy(st, id) {
const t = find(st.tools, id); const t = find(st.tools, id);
if (!t || !t.stakeId) return t; 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 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 // 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). // it (cycle and yield already for this well).
@@ -153,7 +154,8 @@ function removeStake(st, s) {
toolId: tool.id, toolId: tool.id,
storeItemId: tool.storeItemId, storeItemId: tool.storeItemId,
name: tool.name, name: tool.name,
tier: tool.tier, yieldLevel: tool.yieldLevel,
cycleLevel: tool.cycleLevel,
count: 1, count: 1,
cycleSecs: tool.cycleSecs, cycleSecs: tool.cycleSecs,
yield: y, yield: y,
@@ -161,7 +163,6 @@ function removeStake(st, s) {
here: Math.min(y, free), here: Math.min(y, free),
free, free,
short: free < y, short: free < y,
attachments: tool.attachments ?? [],
}); });
} }
} }
+3 -3
View File
@@ -319,7 +319,7 @@ function predictWell(st, w) {
const groups = new Map(); const groups = new Map();
for (const t of st.tools) { for (const t of st.tools) {
if (t.stakeId) continue; 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); const g = groups.get(key);
if (g) { if (g) {
g.count++; g.count++;
@@ -330,7 +330,8 @@ function predictWell(st, w) {
toolId: t.id, toolId: t.id,
storeItemId: t.storeItemId, storeItemId: t.storeItemId,
name: t.name, name: t.name,
tier: t.tier, yieldLevel: t.yieldLevel,
cycleLevel: t.cycleLevel,
count: 1, count: 1,
cycleSecs: t.cycleSecs, cycleSecs: t.cycleSecs,
yield: y, yield: y,
@@ -338,7 +339,6 @@ function predictWell(st, w) {
here: Math.min(y, free), here: Math.min(y, free),
free, free,
short: free < y, short: free < y,
attachments: t.attachments ?? [],
}); });
} }
const quote = st.market && P.find(st.market.quotes, w.resource, "item"); 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 { ReadyButton } from "./buttons.jsx";
import { ViewLink, WideClaim, wideOn } from "./moons.jsx"; import { ViewLink, WideClaim, wideOn } from "./moons.jsx";
import { colorOf, imageOf, toolOf } from "./catalog.js"; 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 { useFlip } from "./flip.js";
import { now } from "./clock.js"; import { now } from "./clock.js";
import { isReady, find } from "./predict.js"; import { isReady, find } from "./predict.js";
@@ -88,7 +88,7 @@ function WellPanel({ st, moon, well, w }) {
{well.idle.length ? ( {well.idle.length ? (
<div class="deploy-cards" ref={cards}> <div class="deploy-cards" ref={cards}>
{well.idle.map((g) => ( {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> </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-thumb">{tool && <img src={tool.image} alt="" />}</span>
<span class="drill-main"> <span class="drill-main">
<span class="drill-name"> <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>
<span class="drill-meta small"> <span class="drill-meta small">
<span class="muted"> <span class="muted">
@@ -192,30 +193,24 @@ function WellPanel({ st, moon, well, w }) {
} }
function DeployCard({ st, g, wellId }) { 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") : ""; const cls = g.short ? (g.here > 0 ? " is-short" : " is-spoken") : "";
return ( return (
<button <button
type="button" type="button"
data-key={key} data-key={key}
class={`btn deploy-card${cls}`} 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}`)} onClick={(e) => act("stake", { tool: g.toolId, target: wellId }, e.currentTarget, `stake:${key}`)}
> >
<span class="deploy-card-media"> <span class="deploy-card-media">
<img src={imageOf(st, g.storeItemId)} alt="" /> <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>} {g.count > 1 && <span class="deploy-card-count num">×{g.count}</span>}
</span> </span>
<span class="deploy-card-info"> <span class="deploy-card-info">
<span class="deploy-card-name"> <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>
<span class="deploy-card-meta"> <span class="deploy-card-meta">
<span class="num"> <span class="num">
+74 -68
View File
@@ -1054,28 +1054,11 @@ a.htmx-request::after {
margin-top: 6px; margin-top: 6px;
} }
/* Drill attachments (store, tool names, drill panel) and advisors. */ /* Advisors' effects. */
.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;
}
.mod-effect { .mod-effect {
color: var(--violet-hi); color: var(--violet-hi);
font-size: 14px; font-size: 14px;
} }
.gear-card .card-stats,
.advisor-card .card-stats { .advisor-card .card-stats {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@@ -1086,42 +1069,79 @@ a.htmx-request::after {
.tool-status { .tool-status {
min-height: 1.5em; min-height: 1.5em;
} }
/* Attachment slots and equipped attachments sit on the image's top-left corner. */ /* A drill's two upgrade tracks (tracks.go), side by side: name and level, value ▸ next,
.tool-card { the button and a need line. Every box has the same rows in every state, so the buttons
position: relative; keep their place (clicker rule). */
.track-ups {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
} }
.tool-gear { .track-up {
position: absolute; display: flex;
top: 8px; flex-direction: column;
left: 8px; 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; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 4px 0; justify-content: center;
max-width: calc(100% - 16px); color: var(--muted);
padding: 4px 6px; border-color: var(--line);
background: rgb(var(--card-2-rgb) / 0.82); cursor: default;
pointer-events: none; }
.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. */ 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; height: auto;
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
overflow: hidden; overflow: hidden;
} }
:is(.tool-card, .store-card, .gear-card) .card-media img { :is(.tool-card, .store-card) .card-media img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
object-position: 50% 30%; /* keep the tops of the tall rigs */ 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; gap: 4px;
padding: 12px 14px 14px; padding: 12px 14px 14px;
} }
:is(.tool-card, .store-card, .gear-card) h2 { :is(.tool-card, .store-card) h2 {
font-size: 15px; font-size: 15px;
letter-spacing: 0.1em; letter-spacing: 0.1em;
} }
@@ -1130,25 +1150,25 @@ a.htmx-request::after {
align-items: baseline; align-items: baseline;
gap: 0 6px; gap: 0 6px;
} }
:is(.tool-card, .store-card, .gear-card) .card-desc { :is(.tool-card, .store-card) .card-desc {
font-size: 13px; font-size: 13px;
} }
:is(.tool-card, .store-card, .gear-card) .card-stats { :is(.tool-card, .store-card) .card-stats {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 2px 16px; gap: 2px 16px;
padding-top: 8px; padding-top: 8px;
} }
:is(.tool-card, .store-card, .gear-card) .card-stats > div { :is(.tool-card, .store-card) .card-stats > div {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 6px; gap: 6px;
} }
:is(.tool-card, .store-card, .gear-card) .card-stats dd { :is(.tool-card, .store-card) .card-stats dd {
margin-top: 0; margin-top: 0;
font-size: 14px; font-size: 14px;
} }
:is(.tool-card, .store-card, .gear-card) .btn.card-buy { :is(.tool-card, .store-card) .btn.card-buy {
height: 34px; height: 34px;
margin-top: 6px; margin-top: 6px;
} }
@@ -1269,18 +1289,13 @@ a.htmx-request::after {
.tier-ladder .is-next .tl-tier::after { .tier-ladder .is-next .tl-tier::after {
content: " ▸"; content: " ▸";
} }
.pips { /* A tool's track levels after its name (" Y2 C1"). */
display: inline-flex; .lv-tag {
gap: 4px; margin-left: 0.4em;
white-space: nowrap;
} }
.pip { .track-ladder li {
width: 9px; grid-template-columns: 3em 1fr 1.6fr 3.6em;
height: 9px;
border: 1px solid var(--violet);
transform: rotate(45deg);
}
.pip.is-on {
background: var(--violet);
} }
.advisor-card.is-hired { .advisor-card.is-hired {
border-color: rgb(var(--ready-rgb) / 0.4); border-color: rgb(var(--ready-rgb) / 0.4);
@@ -1410,30 +1425,30 @@ a.adv-seat-in[aria-current] {
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
:is(.tool-card, .store-card, .gear-card) .card-media { :is(.tool-card, .store-card) .card-media {
width: 112px; width: 112px;
aspect-ratio: auto; aspect-ratio: auto;
} }
:is(.tool-card, .store-card, .gear-card) h2 { :is(.tool-card, .store-card) h2 {
font-size: 14px; font-size: 14px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
:is(.tool-card, .store-card, .gear-card) .card-media img { :is(.tool-card, .store-card) .card-media img {
height: 100%; height: 100%;
} }
:is(.tool-card, .store-card, .gear-card) .card-body { :is(.tool-card, .store-card) .card-body {
padding: 10px 12px 12px; padding: 10px 12px 12px;
} }
:is(.tool-card, .store-card, .gear-card) .card-stats { :is(.tool-card, .store-card) .card-stats {
justify-content: flex-start; justify-content: flex-start;
gap: 2px 12px; gap: 2px 12px;
} }
:is(.tool-card, .store-card, .gear-card) .btn.card-buy { :is(.tool-card, .store-card) .btn.card-buy {
height: auto; height: auto;
min-height: 30px; min-height: 30px;
padding: 4px 8px; padding: 4px 8px;
@@ -1441,9 +1456,6 @@ a.adv-seat-in[aria-current] {
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.tool-gear {
max-width: 96px;
}
} }
.steps { .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 /* 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). */ stats. Two columns in the side panel, one on narrow screens (image on the left). */
.deploy-cards { .deploy-cards {
display: grid; display: grid;
@@ -1626,7 +1638,6 @@ a.adv-seat-in[aria-current] {
object-position: 50% 30%; /* keep the tops of the tall rigs */ object-position: 50% 30%; /* keep the tops of the tall rigs */
} }
.deploy-card-pips,
.deploy-card-count { .deploy-card-count {
position: absolute; position: absolute;
top: 6px; top: 6px;
@@ -1634,11 +1645,6 @@ a.adv-seat-in[aria-current] {
background: rgb(var(--card-2-rgb) / 0.82); background: rgb(var(--card-2-rgb) / 0.82);
} }
.deploy-card-pips {
left: 6px;
align-items: center;
padding: 5px 6px;
}
.deploy-card-count { .deploy-card-count {
right: 6px; 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.storeItems ?? []) origin.set(r, r.id);
for (const r of doc.refinery?.items ?? []) origin.set(r, r.name); 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.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); for (const r of doc.advisors?.items ?? []) origin.set(r, r.name);
} }
adopt(draft); adopt(draft);
@@ -82,7 +81,6 @@ function init() {
}; };
const resourceNames = () => Object.keys(draft.resourcePrices ?? {}).sort(); const resourceNames = () => Object.keys(draft.resourcePrices ?? {}).sort();
const refinedNames = () => (draft.refinery?.items ?? []).map((i) => i.name); 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 advisorNames = () => (draft.advisors?.items ?? []).map((i) => i.name);
const quote = (name) => (data.market ?? []).find((q) => q.name === name); const quote = (name) => (data.market ?? []).find((q) => q.name === name);
const spark = (q) => const spark = (q) =>
@@ -229,6 +227,22 @@ function init() {
{ k: "completionTimeInSecs", label: "Cycle s", type: "int", min: 1 }, { 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. // 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: "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: "_rate", label: "Units/h", calc: (r) => (r.claimAmount * 3600) / r.completionTimeInSecs },
{ {
k: "_payback", k: "_payback",
@@ -241,17 +255,16 @@ function init() {
digits: 1, digits: 1,
}, },
], ],
sub: { // The two upgrade tracks (tracks.go): each level sets the value flat (a claim, or a
label: "Upgrade tiers", // cycle in seconds), with a price and refined parts.
rows: (r) => (r.upgrades ??= []), 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: [ 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: "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", k: "parts",
label: "Parts (refined)", 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) }, { 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: (rows, p) => ({
}, value: rows.at(-1)?.value ?? (kind === "yield" ? p.claimAmount : p.completionTimeInSecs),
newRow: () => ({ id: nextID(), name: "New tool", description: "", price: 0, image: "", claimAmount: 1, completionTimeInSecs: 3, upgrades: [] }), 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)) }), copy: (r) => ({ ...clone(r), id: nextID(), name: uniqueName(r.name + " copy", draft.storeItems.map((x) => x.name)) }),
check(row, col, v) { check(row, col, v) {
if (col.k === "id" && draft.storeItems.filter((r) => r.id === v).length > 1) return "Duplicate id"; 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])), market: kvTable((path) => /^market/i.test(path[0])),
constants: 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 // Advisors (bonus.go). Bonuses are multipliers (1.2 = +20%); the caps and seat prices
// caps, slots per tool and seat prices are in Constants. // 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: { advisors: {
...arrayTable("name", (d) => ((d.advisors ??= {}).items ??= [])), ...arrayTable("name", (d) => ((d.advisors ??= {}).items ??= [])),
noun: "advisor", noun: "advisor",
@@ -492,8 +489,11 @@ function init() {
if (!parent) return table.match(doc, row); if (!parent) return table.match(doc, row);
const p = table.match(doc, parent); const p = table.match(doc, parent);
if (!p) return null; if (!p) return null;
const i = table.sub.rows(parent).indexOf(row); for (const sub of table.subs) {
return (p.upgrades ?? [])[i] ?? null; 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) { function cellState(table, row, col, parent) {
@@ -560,7 +560,7 @@ function init() {
const hasActions = !table.noAdd; const hasActions = !table.noAdd;
let html = "<thead><tr>"; let html = "<thead><tr>";
if (hasFlags) html += '<th class="w-flag" aria-label="Flags"></th>'; 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) { for (const col of table.cols) {
const s = sort?.k === col.k ? (sort.dir > 0 ? " ▲" : " ▼") : ""; 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>`; 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>'; if (hasActions) html += '<th class="w-act"></th>';
html += "</tr></thead><tbody>"; 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) { for (const row of rows) {
const fl = hasFlags ? flagFor(table.flagKey(row)) : []; const fl = hasFlags ? flagFor(table.flagKey(row)) : [];
html += `<tr class="${fl.length ? "has-flag" : ""}${origin.has(row) || !table.newRow ? "" : " is-new"}">`; html += `<tr class="${fl.length ? "has-flag" : ""}${origin.has(row) || !table.newRow ? "" : " is-new"}">`;
if (hasFlags) { if (hasFlags) {
html += `<td class="w-flag">${fl.length ? `<span class="eco-flag" title="${esc(fl.join("\n"))}" tabindex="0">!</span>` : ""}</td>`; 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)); 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); for (const col of table.cols) html += cellHTML(table, row, col);
if (hasActions) { 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 += `<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>"; 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>`; if (!rows.length) html += `<tr><td colspan="${span}" class="muted">No rows${filter ? " match the filter" : ""}.</td></tr>`;
html += "</tbody>"; html += "</tbody>";
@@ -611,10 +612,9 @@ function init() {
}${msg ? ` title="${esc(msg)}"` : ""}>${shown ?? ""}${msg ? `<span class="eco-err">${esc(msg)}</span>` : ""}</td>`; }${msg ? ` title="${esc(msg)}"` : ""}>${shown ?? ""}${msg ? `<span class="eco-err">${esc(msg)}</span>` : ""}</td>`;
} }
function subHTML(table, row, span) { function subHTML(table, sub, j, row, span) {
const sub = table.sub;
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> 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>`; <table class="eco-grid eco-subgrid"><thead><tr>`;
for (const col of sub.cols) html += `<th class="${col.calc ? "right" : ""}">${esc(col.label)}</th>`; 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>'; html += '<th class="w-act"></th></tr></thead><tbody>';
@@ -622,9 +622,9 @@ function init() {
rows.forEach((u, i) => { rows.forEach((u, i) => {
html += "<tr>"; html += "<tr>";
for (const col of sub.cols) html += cellHTML(table, u, col, row); 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>"; return html + "</tbody></table></div></td></tr>";
} }
@@ -810,16 +810,17 @@ function init() {
break; break;
} }
case "add-tier": { case "add-tier": {
const tiers = table.sub.rows(row); const sub = table.subs[Number(btn.dataset.sub)];
tiers.push(table.sub.newRow(tiers)); const levels = sub.rows(row);
levels.push(sub.newRow(levels, row));
break; break;
} }
case "delete-tier": { 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); 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; 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;
tiers.splice(i, 1); levels.splice(i, 1);
tiers.forEach((u, j) => (u.tier = j + 1));
break; break;
} }
} }
@@ -868,7 +869,7 @@ function init() {
$("#eco-status").innerHTML = msg ? `<span class="${error ? "eco-bad" : "eco-ok"}">${esc(msg)}</span>` : ""; $("#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) { function mapErrors(list) {
rowErrors = {}; rowErrors = {};
generalErrors = []; 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); 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. // 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]"); 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). // Claim and sell sound as their numbers arrive (above).
const sound = { const sound = {
"buy-tool": "buy", "buy-tool": "buy",
"buy-attachment": "buy",
equip: "upgrade",
unequip: "toggle",
hire: "confirm", hire: "confirm",
"seat-advisor": "upgrade", "seat-advisor": "upgrade",
"unseat-advisor": "toggle", "unseat-advisor": "toggle",
+49 -93
View File
@@ -356,17 +356,17 @@
{{end}}{{end}} {{end}}{{end}}
{{define "equipment-section"}} {{define "equipment-section"}}
{{$open := .S.Unlocked "store"}}{{/* upgrades, attachments and buying (onboarding.go) */}} {{$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}} Pick one to equip attachments.{{end}}</p></div> <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> {{if $open}}<a href="{{.V.ToSection "store"}}" hx-get="{{.V.ToSection "store"}}" class="btn">Buy tools</a>{{end}}</div>
<div class="cards"> <div class="cards">
{{range .Equipment}} {{range .Equipment}}
{{$row := .}} {{$row := .}}
{{$d := $.V.ToDrill .ID}} {{$d := $.V.ToDrill .ID}}
<article class="card tool-card {{if .Selected}}is-selected{{end}}"> <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"> <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"> <p class="tool-status small">
{{with .Stake}} {{with .Stake}}
{{$url := $.StakeURL .}} {{$url := $.StakeURL .}}
@@ -379,10 +379,7 @@
</p> </p>
{{with .ReachText}}<p class="muted small">{{.}} per cycle</p>{{end}} {{with .ReachText}}<p class="muted small">{{.}} per cycle</p>{{end}}
<dl class="card-stats">{{template "tool-stats" .}}</dl> <dl class="card-stats">{{template "tool-stats" .}}</dl>
{{if $open}} {{if $open}}{{template "track-ups" .}}{{end}}
<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}}
</div> </div>
</article> </article>
{{else}} {{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 "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"}} {{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> <h2 class="refinery-label cap">Tools</h2>
<div class="cards"> <div class="cards">
{{range .Store}} {{range .Store}}
@@ -536,29 +533,10 @@
</article> </article>
{{end}} {{end}}
</div> </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}} {{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"}} {{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> <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>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>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>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> </ol>
<p class="muted">Your progress is saved on this browser.</p> <p class="muted">Your progress is saved on this browser.</p>
<button class="btn" hx-post="/act/new-game" <button class="btn" hx-post="/act/new-game"
@@ -699,14 +677,13 @@
{{if .Idle}} {{if .Idle}}
<div class="deploy-cards"> <div class="deploy-cards">
{{range .Idle}} {{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"> <span class="deploy-card-media">
<img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'"> <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}} {{if gt .Count 1}}<span class="deploy-card-count num">×{{.Count}}</span>{{end}}
</span> </span>
<span class="deploy-card-info"> <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="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> <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}} {{if .ReachShort}}<span class="muted" title="{{.ReachText}}">· {{.ReachShort}}</span>{{end}}
@@ -720,7 +697,7 @@
{{with .Better}} {{with .Better}}
<div class="line better-well"> <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> <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> </div>
{{end}} {{end}}
{{else}} {{else}}
@@ -734,7 +711,7 @@
<div class="line drill-line is-{{.State}}"> <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-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-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> <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> {{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> </span>
@@ -780,53 +757,59 @@
</section> </section>
{{end}} {{end}}
{{/* Side panel for a drill picked in the Equipment section (attachments.go): its slots, {{/* A tool's stats (a ToolRow), for a <dl>: a stake's yield and cycle now. */}}
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. */}}
{{define "tool-stats"}} {{define "tool-stats"}}
{{$u := .Preview}} <div><dt class="cap">Yield</dt><dd class="num">{{money .Yield}}</dd></div>
{{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">Cycle</dt><dd class="num">{{secs .Secs}}</dd></div>
<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>
{{end}} {{end}}
{{/* A tool's next upgrade (a ToolRow): the button and its parts. */}} {{/* A ToolRow's two upgrade tracks side by side (Equipment card, drill panel), each with
{{define "tool-upgrade"}} 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}} {{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> <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>
{{with .Short}}<p class="up-short small num">{{.}}</p>{{end}} <div class="up-need small">{{with .Parts}}{{template "upgrade-parts" .}}{{else}}{{with .Short}}<span class="up-short num">{{.}}</span>{{else}}&nbsp;{{end}}{{end}}</div>
{{with .Parts}}{{template "upgrade-parts" .}}{{end}}
{{else}} {{else}}
<p class="muted small card-buy">Max tier</p> <p class="btn btn-sm card-buy is-max" aria-disabled="true">Max level</p>
<div class="up-need small">&nbsp;</div>
{{end}} {{end}}
</div>
{{end}}
</div>
{{end}} {{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"}} {{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> <dl class="facts su-facts">{{template "tool-stats" .ToolRow}}</dl>
{{/* Before the Store is unlocked: stats, tiers and deploying only (DrillPanel.Store). */}} {{/* Before the Store is unlocked: stats, ladders and deploying only (DrillPanel.Store). */}}
{{if .Store}} {{if .Store}}<div class="drill-upgrade">{{template "track-ups" .ToolRow}}</div>{{end}}
{{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}} {{range .Tracks}}
<div class="drill-upgrade">{{template "tool-upgrade" .ToolRow}}</div> <h3>{{.Name}} <span class="muted small">{{if .Cycle}}cycle{{else}}yield per cycle{{end}}</span></h3>
{{end}} {{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}}
{{with .Ladder}} <ol class="tier-ladder track-ladder num small">
<h3>Tiers</h3> <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>
<ol class="tier-ladder num small"> {{$cycle := .Cycle}}
<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 .Ladder}}
{{range .}}
<li class="{{if .Current}}is-current{{else if .Next}}is-next{{end}}"{{if .Current}} aria-current="true"{{end}}> <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> </li>
{{end}} {{end}}
</ol> </ol>
{{end}} {{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}} {{with .IdleBest}}
{{$b := $.IdleURL}} {{$b := $.IdleURL}}
<div class="line"> <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> <span class="muted small">{{with $.DeployTo}}Best well: {{.Resource}} on {{.MoonName}}{{else}}No well to deploy on{{end}}</span></span>
{{with $.DeployTo}} {{with $.DeployTo}}
<button class="btn btn-sm btn-primary" hx-post="/act/stake" <button class="btn btn-sm btn-primary" hx-post="/act/stake"
@@ -836,37 +819,10 @@
{{end}} {{end}}
{{if .Store}} {{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> <h3>Sell drill</h3>
<div class="line"> <div class="line">
<span class="grow">Sells for <span class="num">{{cur .Resale}}</span><br> <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}}" <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 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> {{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="#resources" data-tab="resources">Resources</a>
<a href="#refined" data-tab="refined">Refined</a> <a href="#refined" data-tab="refined">Refined</a>
<a href="#market" data-tab="market">Market</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="#advisors" data-tab="advisors">Advisors</a>
<a href="#moons" data-tab="moons">Moons</a> <a href="#moons" data-tab="moons">Moons</a>
<a href="#constants" data-tab="constants">Constants</a> <a href="#constants" data-tab="constants">Constants</a>
@@ -185,16 +184,16 @@
</table> </table>
</div> </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"> <div class="table-box">
<table class="table eco-table"> <table class="table eco-table">
<tbody> <tbody>
{{range .Tools}} {{range .Tools}}
<tr> <tr>
<td>{{.Name}}</td> <td>{{.Name}}</td>
{{range .Tiers}} {{range .Levels}}
<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"> <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">
T{{.Tier}} {{money .Yield}} <span class="muted">/ {{.Payback}}</span> {{if eq .Track "cycle"}}C{{.Level}} {{secs .Secs}}{{else}}Y{{.Level}} {{money .Yield}}{{end}} <span class="muted">/ {{.Payback}}</span>
</td> </td>
{{end}} {{end}}
</tr> </tr>
+7 -5
View File
@@ -184,10 +184,12 @@
hx-confirm="Give this tool to {{.P.Name}}?" data-confirm-ok="Give"> hx-confirm="Give this tool to {{.P.Name}}?" data-confirm-ok="Give">
<label class="grow"><span class="label">Tool</span> <label class="grow"><span class="label">Tool</span>
<select class="input" name="item"> <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> </select></label>
<label><span class="label">Tier</span> <label><span class="label">Yield level</span>
<input class="input pl-tier" type="number" name="tier" min="0" step="1" value="0" required></label> <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> <button class="btn btn-primary">Give</button>
</form> </form>
@@ -219,12 +221,12 @@
<h2 id="pl-tools-title">Tools</h2> <h2 id="pl-tools-title">Tools</h2>
<div class="table-box"> <div class="table-box">
<table class="table"> <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> <tbody>
{{range .Tools}} {{range .Tools}}
<tr> <tr>
<td>{{with .Item}}{{.Name}}{{else}}Item #{{.StoreItemID}}{{end}} <span class="muted num small">{{slice .ID 0 8}}</span></td> <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>{{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> <td class="num right">{{if .Stake}}{{if .Remaining}}{{dur .Remaining}}{{else}}<span class="ready">Ready</span>{{end}}{{else}}—{{end}}</td>
</tr> </tr>