Compare commits

..
10 Commits
Author SHA1 Message Date
JosephFeranoandClaude Opus 5.5 cad48d8856 Refinery collect: motes land on the owned number's text (its pivot and text width), not right of and under it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:43:11 +07:00
JosephFeranoandClaude Opus 5.5 7da4ab8103 Client: predict deploys and claims of multi-well drills too (every drill is one now), so deploys and claims show at once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:33:38 +07:00
JosephFeranoandClaude Opus 5.5 80cb1ffb07 Moon packs: 5, 10 or 20 moons of a grade at once (5, 10, 15% off; each moon worth at least 1.4, 1.5, 1.6x its price), once the store is open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:32:48 +07:00
JosephFeranoandClaude Opus 5.5 9b61acd61a Every drill reaches the whole moon: a claim takes what its well lacks from the moon's next wells
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:23:25 +07:00
JosephFeranoandClaude Opus 5.5 b0bc8135bb D moons: wells hold 28–56 units (was 23–46), about a fifth more
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:15:32 +07:00
JosephFeranoandClaude Opus 5.5 1510bf10a3 Drill upgrades cost credits only: no refined parts on any track level
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:10:09 +07:00
JosephFeranoandClaude Opus 5.5 75f1820e83 Equipment: a selected drill's panel takes the whole side pane (no moon over it)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:08:25 +07:00
JosephFeranoandClaude Opus 5.5 8f57dedfe6 Equipment rows: the drill picture, square, takes the row's whole height; Status a little narrower so names fit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:04:32 +07:00
JosephFeranoandClaude Opus 5.5 afb4dc4cca Settings menu rows take real clicks again: the moon viewport ignores the mouse while a dialog is open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:03:00 +07:00
JosephFeranoandClaude Opus 5.5 956d6c506e shot.sh keeps the game's saved files beside its screenshots; make play needs MM_API and passes it
make play ignored MM_API and ran with mm.new_player=1 against localhost:3000 and the
player's own save folder, clearing their saved player id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:51:48 +07:00
20 changed files with 361 additions and 135 deletions

No files matched your search

+4 -2
View File
@@ -90,11 +90,13 @@ run: build
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too). # Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
shot: build shot: build
scripts/shot.sh $(BIN) $(SHOT) $(ARGS) $(if $(POPUP),--config=mm.popup=1) scripts/shot.sh $(BIN) $(SHOT) $(if $(MM_API),--config=mm.api_base=$(MM_API)) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/. # Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
# Needs MM_API (a test server): it makes a new player there.
play: build play: build
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS) @test -n "$(MM_API)" || { echo "make play: set MM_API to a test server (no default)"; exit 1; }
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.api_base=$(MM_API) --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
clean: clean:
rm -rf build dist .internal/cache rm -rf build dist .internal/cache
+19 -1
View File
@@ -1015,9 +1015,27 @@ function M:batch_ready(g, jobs)
end end
end end
-- (a text node's position is its pivot point and its size not its text's: the box is the
-- text's own, round that point)
local PIVOT = { -- (fraction of the box left of / above the pivot point)
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
}
function M:box_of(key) function M:box_of(key)
local b = self.binds[key] local b = self.binds[key]
return b and self:rect_of(b.box or b.node) if not b then
return nil
end
if b.box or gui.get_type(b.node) ~= gui.TYPE_TEXT then
return self:rect_of(b.box or b.node)
end
local r = self:rect_of(b.node)
local k = PIVOT[gui.get_pivot(b.node)] or { 0, 0 }
local w = ui.text_width(b.node)
local h = r.h * gui.get_scale(b.node).y
return { x = r.x - k[1] * w, y = r.y - k[2] * h, w = w, h = h }
end end
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready) function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
+1
View File
@@ -38,6 +38,7 @@ local M = {}
M.id = nil M.id = nil
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel) M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
M.covered = false -- a dialog is open over the page: the viewport takes no drag or wheel
-- Settings: nil until loaded (or failed: then the defaults). Replace the table to change -- Settings: nil until loaded (or failed: then the defaults). Replace the table to change
-- them (moon.script compares by identity; the moon lab does, and Save sets the saved ones). -- them (moon.script compares by identity; the moon lab does, and Save sets the saved ones).
+35 -16
View File
@@ -582,17 +582,17 @@ end
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill -- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
-- count goes up (the server's LastWell). -- count goes up (the server's LastWell).
M.register("claim", function(st, p, ctx) M.register("claim", function(st, p, ctx)
if p["claim-wide"] then
return false
end
local list = {} local list = {}
for _, s in ipairs(st.stakes) do for _, s in ipairs(st.stakes) do
local wide = (s.reach or 1) > 1 local wide = (s.reach or 1) > 1
if done(s, ctx) and (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide)) local scope
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then if p["claim-wide"] then
if wide then scope = wide and s.moonId == p["claim-wide"]
return false -- multi-well drills: the server splits them else
end scope = (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
and (not p["claim-moon"] or s.moonId == p["claim-moon"])
end
if done(s, ctx) and scope then
list[#list + 1] = s list[#list + 1] = s
end end
end end
@@ -600,14 +600,33 @@ M.register("claim", function(st, p, ctx)
return false return false
end end
-- Oldest first, each from what the earlier claims left (claimInTx: at most the supply). -- Oldest first, each from what the earlier claims left (claimInTx: at most the supply).
-- A multi-well drill takes from its well, then the moon's next ones (split).
table.sort(list, function(a, b) return a.start < b.start end) table.sort(list, function(a, b) return a.start < b.start end)
local emptied = {} local emptied = {}
for _, s in ipairs(list) do for _, s in ipairs(list) do
local take = math.max(0, math.min(s.amount or s.take or 0, M.supply(st, s.wellId))) local moon = find(st.moons, s.moonId)
M.add_item(st, s.resource, take * M.skew) if (s.reach or 1) > 1 and moon then
M.add_supply(st, s.wellId, -take) local sup = {}
if take > 0 and M.supply(st, s.wellId) <= 0 then for i, w in ipairs(moon.wells or {}) do
emptied[s.wellId] = true sup[i] = M.supply(st, w.id)
end
for i, take in ipairs(split(moon, s, sup)) do
local w = moon.wells[i]
if take > 0 then
M.add_item(st, w.resource or s.resource, take * M.skew)
M.add_supply(st, w.id, -take)
if M.supply(st, w.id) <= 0 then
emptied[w.id] = true
end
end
end
else
local take = math.max(0, math.min(s.amount or s.take or 0, M.supply(st, s.wellId)))
M.add_item(st, s.resource, take * M.skew)
M.add_supply(st, s.wellId, -take)
if take > 0 and M.supply(st, s.wellId) <= 0 then
emptied[s.wellId] = true
end
end end
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
end end
@@ -633,7 +652,7 @@ M.register("claim", function(st, p, ctx)
end end
local amount = tool and w and (group and group.yield or tool.yield * yield_boost(st, w.resource)) local amount = tool and w and (group and group.yield or tool.yield * yield_boost(st, w.resource))
-- Not on a drained or fully committed well (the server leaves the tool idle). -- Not on a drained or fully committed well (the server leaves the tool idle).
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, 1) or 1) > EPS then if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, tool.reach or 1) or 1) > EPS then
M.add_stake(st, tool, s.moonId, w, ctx, group) M.add_stake(st, tool, s.moonId, w, ctx, group)
local detail = st.moon and find(st.moon.wells, s.wellId) local detail = st.moon and find(st.moon.wells, s.wellId)
if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
@@ -664,7 +683,7 @@ M.register("stake", function(st, p, ctx)
return false -- "Deploy there": the view moves to the other well, wait for it return false -- "Deploy there": the view moves to the other well, wait for it
end end
local tool = M.idle_copy(st, p.tool) local tool = M.idle_copy(st, p.tool)
if not tool or (tool.reach or 1) > 1 then if not tool then
return false return false
end end
local group = nil local group = nil
@@ -677,7 +696,7 @@ M.register("stake", function(st, p, ctx)
end end
-- Refused when the drills on the well take all it has left (fullyCommitted). -- Refused when the drills on the well take all it has left (fullyCommitted).
local amount = group and group.yield or tool.yield * yield_boost(st, w.resource) local amount = group and group.yield or tool.yield * yield_boost(st, w.resource)
if (M.room(st, w.id, amount, 1) or 0) <= EPS then if (M.room(st, w.id, amount, tool.reach or 1) or 0) <= EPS then
return false return false
end end
M.add_stake(st, tool, moon.id, w, ctx, group) M.add_stake(st, tool, moon.id, w, ctx, group)
+1 -1
View File
@@ -605,7 +605,7 @@ function update(self, dt)
end end
function on_input(self, action_id, action) function on_input(self, action_id, action)
local rect = moon.frame.visible and moon.rect local rect = moon.frame.visible and not moon.covered and moon.rect
if not rect then if not rect then
self.dragging = false self.dragging = false
return false return false
+7 -1
View File
@@ -435,7 +435,13 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
local section = g.view.section local section = g.view.section
local moon = section == "moons" and st.moon or nil local moon = section == "moons" and st.moon or nil
local cy local cy
if row then -- A section panel that wants the whole aside (the drill panel): no moon over it.
local full = mod and mod.aside_full and st[section] and mod.aside_full(g)
if full then
moon_view.hide()
g.viewport_node, g.viewport_rect = nil, nil
cy = 8
elseif row then
local vp = M.row_viewport(w) local vp = M.row_viewport(w)
viewport(g, root, pad, pad, vp, shown_moon(g)) viewport(g, root, pad, pad, vp, shown_moon(g))
-- The side panel gets the rest of the row as its own box. -- The side panel gets the rest of the row as its own box.
+2
View File
@@ -670,6 +670,8 @@ function M.update(g, dt)
g.fx:update(dt) g.fx:update(dt)
g.reveal:update(dt) g.reveal:update(dt)
g.dialog:update(dt) g.dialog:update(dt)
-- (moon.script takes input before the GUI: a menu over the viewport must get its clicks)
moon_view.covered = g.dialog:is_open()
cards.ask_update(g) cards.ask_update(g)
check_input(g) check_input(g)
tooltip.update(g.dialog:is_open()) tooltip.update(g.dialog:is_open())
+18 -10
View File
@@ -167,8 +167,7 @@ local function geometry(inner)
return { return {
wide = wide, wide = wide,
gap = wide and 12 or 10, gap = wide and 12 or 10,
media_w = wide and 48 or 40, media_h = wide and 40 or 34, status = wide and math.min(200, math.floor(inner * 0.16)) or 0,
status = wide and math.min(230, math.floor(inner * 0.22)) or 0,
track = wide and 124 or 90, track = wide and 124 or 90,
sell = wide and 150 or 60, sell = wide and 150 or 60,
name_px = wide and 14 or 13, name_px = wide and 14 or 13,
@@ -382,14 +381,17 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
name.done = function(n) name.done = function(n)
tooltip.add(n, t.name .. ": yield level " .. (t.yieldLevel or 0) .. ", cycle level " .. (t.cycleLevel or 0)) tooltip.add(n, t.name .. ": yield level " .. (t.yieldLevel or 0) .. ", cycle level " .. (t.cycleLevel or 0))
end end
local rh = row_h(open, wide)
-- The picture, square, takes the row's whole height left of every line (narrow: at most 64).
local mh = wide and rh - PADY * 2 or math.min(rh - PADY * 2, 64)
local mw = mh
local media = made({ width = mw, height = mh, shrink = 0 }, function(p, x, y)
local m = ui.rect(p, x, y, mw, mh, "bg")
ui.outline(m, 0, 0, mw, mh, "line")
cards.image(m, t.image, (mw - mh) / 2, 0, mh)
end)
local lines = { local lines = {
row({ height = LINE1, gap = gap, align = "center" }, { row({ height = LINE1, gap = gap, align = "center" }, {
made({ width = geo.media_w, height = geo.media_h, shrink = 0 }, function(p, x, y)
local mw, mh = geo.media_w, geo.media_h
local m = ui.rect(p, x, y, mw, mh, "bg")
ui.outline(m, 0, 0, mw, mh, "line")
cards.image(m, t.image, (mw - mh) / 2, 0, mh)
end),
col({ grow = 1, shrink = 1, basis = 0 }, { col({ grow = 1, shrink = 1, basis = 0 }, {
name, name,
fg.label(c, tagline, { font = "mono", px = 12, color = "muted" }, 16, { fit = true }), fg.label(c, tagline, { font = "mono", px = 12, color = "muted" }, 16, { fit = true }),
@@ -443,9 +445,10 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
lines[#lines + 1] = space(0, 6) lines[#lines + 1] = space(0, 6)
lines[#lines + 1] = row({ height = 20, align = "center" }, { left }) lines[#lines + 1] = row({ height = 20, align = "center" }, { left })
end end
local rh = row_h(open, wide)
local selected = t.selected and self.view.drill ~= "" local selected = t.selected and self.view.drill ~= ""
return col({ width = w, height = rh, padding = { PADY, PADX, PADY, PADX } }, lines, { make = function(p, x, y, bw, bh) return row({ width = w, height = rh, gap = gap, padding = { PADY, PADX, PADY, PADX } }, {
media, col({ grow = 1, shrink = 1, basis = 0 }, lines),
}, { make = function(p, x, y, bw, bh)
local slot = ui.rect(p, x, y, bw, bh, "none") local slot = ui.rect(p, x, y, bw, bh, "none")
local body = ui.rect(slot, 0, 0, bw, bh, "cyan", 0) local body = ui.rect(slot, 0, 0, bw, bh, "cyan", 0)
row_c.bind(self, body, bw, bh, { selected = selected, id = "drill:" .. t.id, label = t.name, on_click = function() row_c.bind(self, body, bw, bh, { selected = selected, id = "drill:" .. t.id, label = t.name, on_click = function()
@@ -576,6 +579,11 @@ local function ladder(st, c, tr, inner)
return col({}, { box({}, { head, rate_head }), col({ gap = 1 }, rows), space(0, 11) }) return col({}, { box({}, { head, rate_head }), col({ gap = 1 }, rows), space(0, 11) })
end end
-- The drill panel takes the whole aside (core/aside.lua: no moon over it).
function M.aside_full(self)
return self.state.equipment.drill ~= nil
end
function M.aside(self, root, w, h, H) function M.aside(self, root, w, h, H)
local st = self.state local st = self.state
local d = st.equipment and st.equipment.drill local d = st.equipment and st.equipment.drill
+19
View File
@@ -147,6 +147,25 @@ function M.build_teaser(g, root, px, py, inner)
ui.rect(p, x + 16, y, w - 32, 1, "edge") ui.rect(p, x + 16, y, w - 32, 1, "edge")
return nil return nil
end } or nil) end } or nil)
-- Moon packs (once the store is open): several at once, cheaper and better, a button
-- each under the grade's facts.
if #(e.packs or {}) > 0 then
local packs = {}
for _, pk in ipairs(e.packs) do
local label = pk.count .. " moons · " .. theme.currency .. ui.money(pk.price)
packs[#packs + 1] = fg.button(root, label, { small = true, disabled = not pk.canAfford }, function(b, nodes)
local save = math.floor(pk.discount * 100 + 0.5)
tooltip.add(b, pk.canAfford and (pk.count .. " " .. e.grade .. " moons for " .. theme.currency .. ui.money(pk.price)
.. " (" .. save .. "% off), each a little richer than a single one")
or ("Need " .. theme.currency .. ui.money(pk.price - st.credits) .. " more"))
if pk.canAfford then
g.h.action(g, b, nodes, "buy-moon", { grade = e.grade, count = pk.count, include = "new-moon" },
function() g.nm_grade = nil end, nil, "newmoon:pack:" .. e.grade .. ":" .. pk.count)
end
end)
end
rows[#rows + 1] = row({ height = 40, align = "start", padding = { 0, 16, 10, 56 }, gap = 8 }, packs)
end
end end
local picker = panel_box({ width = inner, padding = { 14, 0, 6, 0 } }, { local picker = panel_box({ width = inner, padding = { 14, 0, 6, 0 } }, {
row({ padding = { 0, 16, 0, 16 } }, { fg.label(root, "Moon grades", "cap", 12) }), row({ padding = { 0, 16, 0, 16 } }, { fg.label(root, "Moon grades", "cap", 12) }),
+25
View File
@@ -0,0 +1,25 @@
"""The settings menu's rows take real mouse clicks (xdotool), also where the menu lies over
the moon viewport (its drag used to take the press)."""
def run(game, args):
game.idle()
checks = [
("mute", "tostring(require('modules.sfx').muted())"),
("theme", "tostring(require('modules.theme').light)"),
("motion", "tostring(require('modules.anim').reduced)"),
("dialog:fullscreen", "tostring(mmwindow.is_fullscreen())"),
("menu:save-code", "tostring(g.dialog:is_open())"),
]
for id_, expr in checks:
game.os_click("settings")
game.wait_for("g.dialog:is_open()", timeout=5)
game.sleep(0.8)
before = game.eval(expr)
game.os_click(id_)
game.sleep(1.2)
after = game.eval(expr)
print(" %-18s %s -> %s dialog open: %s" % (id_, before, after, game.eval("tostring(g.dialog:is_open())")))
game.shot("after-" + id_.replace(":", "_"), settle=0.2)
game.key("key_esc")
game.sleep(0.6)
+4
View File
@@ -17,6 +17,10 @@ if [ -z "${MM_IN_XVFB:-}" ]; then
fi fi
export LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-1} export LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-1}
# The game's saved files (player ids, settings) go beside the screenshot, never into the
# player's own (~/.local/share/moonminers): mm.new_player=1 would clear their saved id.
export XDG_DATA_HOME=${MM_SHOT_DATA:-$(dirname "$out")/data}
mkdir -p "$XDG_DATA_HOME"
cd "$(dirname "$bin")" cd "$(dirname "$bin")"
log=$(dirname "$out")/shot.log log=$(dirname "$out")/shot.log
TIMEOUT=${TIMEOUT:-120} TIMEOUT=${TIMEOUT:-120}
+1 -1
View File
@@ -98,7 +98,7 @@ every command below from this directory (`server/`).
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).
- `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`, - `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`,
`tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds `tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds
flat, with a price and, higher up, refined `parts`. Levels are per tool flat, with a price and optional refined `parts` (none in game.json now: credits only). Levels are per tool
(`inventory_item.yield_level`, `cycle_level`; copies with the same levels are (`inventory_item.yield_level`, `cycle_level`; copies with the same levels are
interchangeable, `pickTool`); action `upgrade` (`track=yield|cycle`), counted from the next 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 stake. Reach is the item's and never changes. Also the view models (`TrackRow` with its
+2 -1
View File
@@ -39,7 +39,8 @@ game is moon and drill prices with wells scaled alike.
Attachments and the T1–T5 tier ladder are gone. Each drill has two tracks of its own 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 (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. cycle) and cycle (seconds, to a tenth). Reach never changes. Levels cost credits only (the refined parts they once took gated the
early game behind a refinery the player doesn't have yet; the code still takes `parts`).
Prices: level k of yield costs 0.97 × drill price × (Δclaim / base claim) × (base cycle / 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 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 cycle / cycle), × 0.95^(k−1), minus the parts' value, two significant digits, never below
+1 -1
View File
@@ -74,7 +74,7 @@ Core-loop actions and their params:
| action | params | notes | | action | params | notes |
|---|---|---| |---|---|---|
| `buy-moon` | `grade` (optional: `D`, `C`, `B`, `A`, `S`; default `state.moonGrade`, the grade of the newest moon, else the lowest) | view moves to the new moon; an unknown grade is a rule error | | `buy-moon` | `grade` (optional: `D`, `C`, `B`, `A`, `S`; default `state.moonGrade`, the grade of the newest moon, else the lowest) | view moves to the new moon; an unknown grade is a rule error. `count` (optional): a moon pack's size (`newMoon.grades[].packs`: 5, 10, 20 once the store is open) buys that many at once, at the pack's price, each moon at least the pack's floor; the view and `reveal` show the best of them |
| `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used. Refused ("Nothing left to mine on this well") when the drills already on the moon will take all the tool could get (its deploy card's `here` is 0, `blocked`) | | `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used. Refused ("Nothing left to mine on this well") when the drills already on the moon will take all the tool could get (its deploy card's `here` is 0, `blocked`) |
| `claim` | `claim-well` or `claim-moon` or `claim-wide` (moon id), `redeploy=1` | no scope: claim all (needs `unlocked["claim-all"]`); `redeploy` needs `unlocked.redeploy`; a tool whose well is drained or has no room left (as `stake`) stays idle | | `claim` | `claim-well` or `claim-moon` or `claim-wide` (moon id), `redeploy=1` | no scope: claim all (needs `unlocked["claim-all"]`); `redeploy` needs `unlocked.redeploy`; a tool whose well is drained or has no room left (as `stake`) stays idle |
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well, until it has no room left: each time the smallest that fills what is left alone, else the biggest (as `stake`; the rest stay idle, refused if none fits) | | `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well, until it has no room left: each time the smallest that fills what is left alone, else the biggest (as `stake`; the rest stay idle, refused if none fits) |
+15 -1
View File
@@ -851,13 +851,27 @@ type APIMoonGrade struct {
SupplyMax float64 `json:"supplyMax"` SupplyMax float64 `json:"supplyMax"`
Default bool `json:"default"` // what buy-moon without a grade buys Default bool `json:"default"` // what buy-moon without a grade buys
WhyText string `json:"whyText"` // why its buy button is disabled, "" if it isn't WhyText string `json:"whyText"` // why its buy button is disabled, "" if it isn't
// Moon packs (once the store is open): buy-moon with grade=<grade>&count=<count>.
Packs []APIMoonPack `json:"packs"`
}
// APIMoonPack is count moons of a grade bought at once, for price (discount off; each moon
// at least a little better than a single one's floor).
type APIMoonPack struct {
Count int `json:"count"`
Price float64 `json:"price"`
Discount float64 `json:"discount"`
CanAfford bool `json:"canAfford"`
} }
func apiMoonGrades(gs []GradeOffer) []APIMoonGrade { func apiMoonGrades(gs []GradeOffer) []APIMoonGrade {
out := []APIMoonGrade{} out := []APIMoonGrade{}
for _, g := range gs { for _, g := range gs {
a := APIMoonGrade{Grade: g.Grade, Price: g.Price, CanAfford: g.CanAfford, WellsMin: g.WellsMin, WellsMax: g.WellsMax, a := APIMoonGrade{Grade: g.Grade, Price: g.Price, CanAfford: g.CanAfford, WellsMin: g.WellsMin, WellsMax: g.WellsMax,
SupplyMin: g.SupplyMin, SupplyMax: g.SupplyMax, Default: g.Default} SupplyMin: g.SupplyMin, SupplyMax: g.SupplyMax, Default: g.Default, Packs: []APIMoonPack{}}
for _, p := range g.Packs {
a.Packs = append(a.Packs, APIMoonPack{Count: p.Count, Price: p.Price, Discount: p.Discount, CanAfford: p.CanAfford})
}
if !g.CanAfford { if !g.CanAfford {
a.WhyText = "Not enough credits" a.WhyText = "Not enough credits"
} }
+35
View File
@@ -13,6 +13,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"math"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -30,6 +31,8 @@ type GameConfig struct {
StartingTools []int `json:"startingTools"` // store item ids, given free StartingTools []int `json:"startingTools"` // store item ids, given free
// Moon vendors, cheapest first (rollWells in game.go); the first is the default grade. // Moon vendors, cheapest first (rollWells in game.go); the first is the default grade.
MoonGrades []MoonGrade `json:"moonGrades"` MoonGrades []MoonGrade `json:"moonGrades"`
// Packs of moons bought at once (buyMoons in game.go), smallest first.
MoonPacks []MoonPack `json:"moonPacks,omitempty"`
// Selling a moon back (resale.go). // Selling a moon back (resale.go).
MoonResale MoonResale `json:"moonResale"` MoonResale MoonResale `json:"moonResale"`
// Selling tools back (resale.go). // Selling tools back (resale.go).
@@ -68,6 +71,30 @@ type MoonGrade struct {
MinValue float64 `json:"minValue,omitempty"` MinValue float64 `json:"minValue,omitempty"`
} }
// MoonPack is count moons of one grade bought at once: discount off their price, and each
// moon worth at least minValue × its grade's price (re-rolled like a grade's minValue,
// whichever floor is higher).
type MoonPack struct {
Count int `json:"count"`
Discount float64 `json:"discount"`
MinValue float64 `json:"minValue,omitempty"`
}
// pack is the moon pack of that size (nil: none, or a single moon).
func (cfg *GameConfig) pack(count int) *MoonPack {
for i := range cfg.MoonPacks {
if cfg.MoonPacks[i].Count == count {
return &cfg.MoonPacks[i]
}
}
return nil
}
// PackPrice is what count moons of the grade cost in this pack.
func (p *MoonPack) PackPrice(g *MoonGrade) float64 {
return math.Round(float64(p.Count) * g.Price * (1 - p.Discount))
}
// grade is the moon grade with that name, else the first (lowest) one. // grade is the moon grade with that name, else the first (lowest) one.
func (cfg *GameConfig) grade(name string) *MoonGrade { func (cfg *GameConfig) grade(name string) *MoonGrade {
if i := cfg.gradeIndex(name); i >= 0 { if i := cfg.gradeIndex(name); i >= 0 {
@@ -162,6 +189,14 @@ func (cfg *GameConfig) validate() error {
if err := cfg.MarketPressure.validate(); err != nil { if err := cfg.MarketPressure.validate(); err != nil {
return err return err
} }
for i, p := range cfg.MoonPacks {
if p.Count < 2 || p.Discount < 0 || p.Discount >= 1 || p.MinValue < 0 || p.MinValue > 3 {
return fmt.Errorf("moon pack %d: count at least 2, discount from 0 to below 1, minValue 0 to 3", i+1)
}
if i > 0 && p.Count <= cfg.MoonPacks[i-1].Count {
return fmt.Errorf("moon packs: smallest first, no two the same size")
}
}
if err := cfg.MoonResale.validate(); err != nil { if err := cfg.MoonResale.validate(); err != nil {
return err return err
} }
+49 -47
View File
@@ -3,7 +3,7 @@
"startingResources": 0, "startingResources": 0,
"startingTools": [1], "startingTools": [1],
"moonGrades": [ "moonGrades": [
{"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 23, "wellSupplyMax": 46, "wellCountWeights": {"1": 40, "2": 60}, {"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 28, "wellSupplyMax": 56, "wellCountWeights": {"1": 40, "2": 60},
"affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}}, "affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}},
{"grade": "C", "price": 12000, "minValue": 1.3, "wellSupplyMin": 432, "wellSupplyMax": 860, "wellCountWeights": {"2": 50, "3": 50}, {"grade": "C", "price": 12000, "minValue": 1.3, "wellSupplyMin": 432, "wellSupplyMax": 860, "wellCountWeights": {"2": 50, "3": 50},
"affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}}, "affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}},
@@ -14,6 +14,8 @@
{"grade": "S", "price": 24000000, "wellSupplyMin": 337840, "wellSupplyMax": 829840, "wellCountWeights": {"4": 50, "5": 50}, {"grade": "S", "price": 24000000, "wellSupplyMin": 337840, "wellSupplyMax": 829840, "wellCountWeights": {"4": 50, "5": 50},
"affinities": {"Sollux": 0.4, "Shadowstone": 0.6, "Azurium": 1, "Novafor": 2, "Nebulance": 3.5}} "affinities": {"Sollux": 0.4, "Shadowstone": 0.6, "Azurium": 1, "Novafor": 2, "Nebulance": 3.5}}
], ],
"moonPacks": [{"count": 5, "discount": 0.05, "minValue": 1.4}, {"count": 10, "discount": 0.1, "minValue": 1.5},
{"count": 20, "discount": 0.15, "minValue": 1.6}],
"moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600}, "moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600},
"equipmentResale": {"factor": 0.5}, "equipmentResale": {"factor": 0.5},
"marketVolatility": 0.25, "marketVolatility": 0.25,
@@ -33,20 +35,20 @@
"image": "shovel.png", "image": "shovel.png",
"price": 1200, "price": 1200,
"claimAmount": 8, "claimAmount": 8,
"completionTimeInSecs": 3, "completionTimeInSecs": 3, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Twin rotors", "levels": [ "yield": {"name": "Twin rotors", "levels": [
{"value": 10, "price": 280}, {"value": 10, "price": 280},
{"value": 12, "price": 280}, {"value": 12, "price": 280},
{"value": 14, "price": 290, "parts": {"Dusk Alloy": 1}}, {"value": 14, "price": 290},
{"value": 16, "price": 280, "parts": {"Dusk Alloy": 2}} {"value": 16, "price": 280}
]}, ]},
"cycle": {"name": "Flight computer", "levels": [ "cycle": {"name": "Flight computer", "levels": [
{"value": 2.6, "price": 180}, {"value": 2.6, "price": 180},
{"value": 2.2, "price": 240}, {"value": 2.2, "price": 240},
{"value": 1.9, "price": 250, "parts": {"Dusk Alloy": 1}}, {"value": 1.9, "price": 250},
{"value": 1.7, "price": 210, "parts": {"Dusk Alloy": 1}}, {"value": 1.7, "price": 210},
{"value": 1.5, "price": 270, "parts": {"Dusk Alloy": 2}} {"value": 1.5, "price": 270}
]} ]}
} }
}, },
@@ -57,21 +59,21 @@
"image": "pickaxe.png", "image": "pickaxe.png",
"price": 8000, "price": 8000,
"claimAmount": 120, "claimAmount": 120,
"completionTimeInSecs": 6, "completionTimeInSecs": 6, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Wider chisel", "levels": [ "yield": {"name": "Wider chisel", "levels": [
{"value": 144, "price": 1520}, {"value": 144, "price": 1520},
{"value": 168, "price": 1520}, {"value": 168, "price": 1520},
{"value": 192, "price": 1520, "parts": {"Dusk Alloy": 2}}, {"value": 192, "price": 1520},
{"value": 216, "price": 1520, "parts": {"Cryo Cell": 1}}, {"value": 216, "price": 1520},
{"value": 240, "price": 1520, "parts": {"Plasma Weave": 1}} {"value": 240, "price": 1520}
]}, ]},
"cycle": {"name": "Pneumatic hammer", "levels": [ "cycle": {"name": "Pneumatic hammer", "levels": [
{"value": 5.2, "price": 1160}, {"value": 5.2, "price": 1160},
{"value": 4.5, "price": 1360}, {"value": 4.5, "price": 1360},
{"value": 3.9, "price": 1560, "parts": {"Dusk Alloy": 2}}, {"value": 3.9, "price": 1560},
{"value": 3.4, "price": 1720, "parts": {"Cryo Cell": 1}}, {"value": 3.4, "price": 1720},
{"value": 3, "price": 1780, "parts": {"Plasma Weave": 1}} {"value": 3, "price": 1780}
]} ]}
} }
}, },
@@ -82,21 +84,21 @@
"image": "lunarlite.jpg", "image": "lunarlite.jpg",
"price": 40200, "price": 40200,
"claimAmount": 900, "claimAmount": 900,
"completionTimeInSecs": 8, "completionTimeInSecs": 8, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Wide-bore bit", "levels": [ "yield": {"name": "Wide-bore bit", "levels": [
{"value": 1060, "price": 6900}, {"value": 1060, "price": 6900},
{"value": 1270, "price": 8700}, {"value": 1270, "price": 8700},
{"value": 1430, "price": 6900, "parts": {"Cryo Cell": 1}}, {"value": 1430, "price": 6900},
{"value": 1640, "price": 8700, "parts": {"Plasma Weave": 2}}, {"value": 1640, "price": 8700},
{"value": 1800, "price": 6900, "parts": {"Plasma Weave": 3}} {"value": 1800, "price": 6900}
]}, ]},
"cycle": {"name": "Spindle motor", "levels": [ "cycle": {"name": "Spindle motor", "levels": [
{"value": 7, "price": 5400}, {"value": 7, "price": 5400},
{"value": 6, "price": 7200}, {"value": 6, "price": 7200},
{"value": 5.2, "price": 7800, "parts": {"Cryo Cell": 1}}, {"value": 5.2, "price": 7800},
{"value": 4.6, "price": 7800, "parts": {"Plasma Weave": 2}}, {"value": 4.6, "price": 7800},
{"value": 4, "price": 9900, "parts": {"Plasma Weave": 3}} {"value": 4, "price": 9900}
]} ]}
} }
}, },
@@ -108,21 +110,21 @@
"price": 645000, "price": 645000,
"claimAmount": 20000, "claimAmount": 20000,
"completionTimeInSecs": 10, "completionTimeInSecs": 10,
"reach": 2, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Wider beam", "levels": [ "yield": {"name": "Wider beam", "levels": [
{"value": 23700, "price": 115000}, {"value": 23700, "price": 115000},
{"value": 28200, "price": 135000}, {"value": 28200, "price": 135000},
{"value": 31800, "price": 115000, "parts": {"Cryo Cell": 2}}, {"value": 31800, "price": 115000},
{"value": 36300, "price": 135000, "parts": {"Plasma Weave": 3}}, {"value": 36300, "price": 135000},
{"value": 40000, "price": 115000, "parts": {"Void Core": 1}} {"value": 40000, "price": 115000}
]}, ]},
"cycle": {"name": "Laser capacitors", "levels": [ "cycle": {"name": "Laser capacitors", "levels": [
{"value": 8.7, "price": 90000}, {"value": 8.7, "price": 90000},
{"value": 7.5, "price": 115000}, {"value": 7.5, "price": 115000},
{"value": 6.5, "price": 125000, "parts": {"Cryo Cell": 2}}, {"value": 6.5, "price": 125000},
{"value": 5.7, "price": 130000, "parts": {"Plasma Weave": 3}}, {"value": 5.7, "price": 130000},
{"value": 5, "price": 150000, "parts": {"Void Core": 1}} {"value": 5, "price": 150000}
]} ]}
} }
}, },
@@ -134,21 +136,21 @@
"price": 9440000, "price": 9440000,
"claimAmount": 384000, "claimAmount": 384000,
"completionTimeInSecs": 12, "completionTimeInSecs": 12,
"reach": 3, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Fine sieves", "levels": [ "yield": {"name": "Fine sieves", "levels": [
{"value": 452000, "price": 1600000}, {"value": 452000, "price": 1600000},
{"value": 542000, "price": 2080000}, {"value": 542000, "price": 2080000},
{"value": 610000, "price": 1600000, "parts": {"Plasma Weave": 3}}, {"value": 610000, "price": 1600000},
{"value": 700000, "price": 2080000, "parts": {"Void Core": 1}}, {"value": 700000, "price": 2080000},
{"value": 768000, "price": 1600000, "parts": {"Stellar Lattice": 1}} {"value": 768000, "price": 1600000}
]}, ]},
"cycle": {"name": "Auger gearbox", "levels": [ "cycle": {"name": "Auger gearbox", "levels": [
{"value": 10.4, "price": 1360000}, {"value": 10.4, "price": 1360000},
{"value": 9, "price": 1600000}, {"value": 9, "price": 1600000},
{"value": 7.8, "price": 1840000, "parts": {"Plasma Weave": 3}}, {"value": 7.8, "price": 1840000},
{"value": 6.8, "price": 2000000, "parts": {"Void Core": 1}}, {"value": 6.8, "price": 2000000},
{"value": 6, "price": 2080000, "parts": {"Stellar Lattice": 1}} {"value": 6, "price": 2080000}
]} ]}
} }
}, },
@@ -165,16 +167,16 @@
"yield": {"name": "Larger plates", "levels": [ "yield": {"name": "Larger plates", "levels": [
{"value": 1050000, "price": 3120000}, {"value": 1050000, "price": 3120000},
{"value": 1220000, "price": 3120000}, {"value": 1220000, "price": 3120000},
{"value": 1420000, "price": 3600000, "parts": {"Void Core": 1}}, {"value": 1420000, "price": 3600000},
{"value": 1590000, "price": 3120000, "parts": {"Stellar Lattice": 1}}, {"value": 1590000, "price": 3120000},
{"value": 1760000, "price": 3120000, "parts": {"Void Core": 2, "Stellar Lattice": 2}} {"value": 1760000, "price": 3120000}
]}, ]},
"cycle": {"name": "Charge coils", "levels": [ "cycle": {"name": "Charge coils", "levels": [
{"value": 12.2, "price": 2400000}, {"value": 12.2, "price": 2400000},
{"value": 10.5, "price": 2960000}, {"value": 10.5, "price": 2960000},
{"value": 9.1, "price": 3280000, "parts": {"Void Core": 1}}, {"value": 9.1, "price": 3280000},
{"value": 8, "price": 3440000, "parts": {"Stellar Lattice": 1}}, {"value": 8, "price": 3440000},
{"value": 7, "price": 4000000, "parts": {"Void Core": 2, "Stellar Lattice": 2}} {"value": 7, "price": 4000000}
]} ]}
} }
}, },
@@ -191,16 +193,16 @@
"yield": {"name": "Core barrel", "levels": [ "yield": {"name": "Core barrel", "levels": [
{"value": 1910000, "price": 4480000}, {"value": 1910000, "price": 4480000},
{"value": 2250000, "price": 5040000}, {"value": 2250000, "price": 5040000},
{"value": 2550000, "price": 4480000, "parts": {"Void Core": 2}}, {"value": 2550000, "price": 4480000},
{"value": 2890000, "price": 5040000, "parts": {"Stellar Lattice": 2}}, {"value": 2890000, "price": 5040000},
{"value": 3200000, "price": 4480000, "parts": {"Void Core": 3, "Stellar Lattice": 3}} {"value": 3200000, "price": 4480000}
]}, ]},
"cycle": {"name": "Drill string", "levels": [ "cycle": {"name": "Drill string", "levels": [
{"value": 14, "price": 3360000}, {"value": 14, "price": 3360000},
{"value": 12, "price": 4480000}, {"value": 12, "price": 4480000},
{"value": 10.4, "price": 4800000, "parts": {"Void Core": 2}}, {"value": 10.4, "price": 4800000},
{"value": 9.1, "price": 5200000, "parts": {"Stellar Lattice": 2}}, {"value": 9.1, "price": 5200000},
{"value": 8, "price": 5680000, "parts": {"Void Core": 3, "Stellar Lattice": 3}} {"value": 8, "price": 5680000}
]} ]}
} }
} }
+93 -48
View File
@@ -616,11 +616,17 @@ func createUser(ctx context.Context, db *pgxpool.Pool, name string) (string, err
// grade with a minValue rolls again while the moon is worth less than that × its price at // grade with a minValue rolls again while the moon is worth less than that × its price at
// base prices (at most 64 times; then the best roll). // base prices (at most 64 times; then the best roll).
func rollWells(c *GameConfig, g *MoonGrade, kind MoonType, first bool, rnd func() float64) []wellSupply { func rollWells(c *GameConfig, g *MoonGrade, kind MoonType, first bool, rnd func() float64) []wellSupply {
return rollWellsAtLeast(c, g, kind, first, rnd, g.MinValue)
}
// rollWellsAtLeast is rollWells with the floor minValue × the grade's price (a moon pack's
// is higher than its grade's).
func rollWellsAtLeast(c *GameConfig, g *MoonGrade, kind MoonType, first bool, rnd func() float64, minValue float64) []wellSupply {
wells := rollWellsOnce(c, g, kind, first, rnd) wells := rollWellsOnce(c, g, kind, first, rnd)
if g.MinValue <= 0 { if minValue <= 0 {
return wells return wells
} }
floor, best := g.MinValue*g.Price, wellsValue(c, wells) floor, best := minValue*g.Price, wellsValue(c, wells)
for try := 0; best < floor && try < 64; try++ { for try := 0; best < floor && try < 64; try++ {
w := rollWellsOnce(c, g, kind, first, rnd) w := rollWellsOnce(c, g, kind, first, rnd)
if v := wellsValue(c, w); v > best { if v := wellsValue(c, w); v > best {
@@ -739,13 +745,32 @@ func rollMoonName(n MoonNames, rnd func() float64) string {
// grade is the moon grade's name ("D", …); "" buys the grade of the player's newest moon, // grade is the moon grade's name ("D", …); "" buys the grade of the player's newest moon,
// or the lowest. // or the lowest.
func buyMoon(ctx context.Context, db *pgxpool.Pool, userID, grade string) (string, error) { func buyMoon(ctx context.Context, db *pgxpool.Pool, userID, grade string) (string, error) {
var moonID string ids, err := buyMoons(ctx, db, userID, grade, 1)
if grade != "" && cfg().gradeIndex(grade) < 0 { if err != nil {
return "", gameErr("Unknown moon grade") return "", err
} }
return ids[0], nil
}
// buyMoons claims count moons of the grade at once: 1, or a moon pack's size (config
// moonPacks: its discount off the price, its floor on each moon's value). It returns the
// moons' ids, the best rated first. Packs come with the store.
func buyMoons(ctx context.Context, db *pgxpool.Pool, userID, grade string, count int) ([]string, error) {
if grade != "" && cfg().gradeIndex(grade) < 0 {
return nil, gameErr("Unknown moon grade")
}
var pack *MoonPack
if count != 1 {
if pack = cfg().pack(count); pack == nil {
return nil, gameErr("No moon pack of that size")
}
}
var ids []string
err := inTx(ctx, db, func(tx pgx.Tx) error { err := inTx(ctx, db, func(tx pgx.Tx) error {
ids = nil
// Locking the player's row keeps moon names unique and the moon count right. // Locking the player's row keeps moon names unique and the moon count right.
_, err := tx.Exec(ctx, "select 1 from users where id = $1 for no key update", userID) var onboarding []string
err := tx.QueryRow(ctx, "select onboarding from users where id = $1 for no key update", userID).Scan(&onboarding)
if err != nil { if err != nil {
return err return err
} }
@@ -754,6 +779,9 @@ func buyMoon(ctx context.Context, db *pgxpool.Pool, userID, grade string) (strin
if err := newMoonAllowed(ctx, tx, userID); err != nil { if err := newMoonAllowed(ctx, tx, userID); err != nil {
return err return err
} }
if pack != nil && !(&State{Onboarding: onboarding}).Unlocked("store") {
return gameErr("Not available yet")
}
var owned int var owned int
var last string var last string
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
@@ -767,13 +795,15 @@ func buyMoon(ctx context.Context, db *pgxpool.Pool, userID, grade string) (strin
grade = last grade = last
} }
g := cfg().grade(grade) g := cfg().grade(grade)
price := g.Price price, floor := g.Price, g.MinValue
if pack != nil {
price, floor = pack.PackPrice(g), max(floor, pack.MinValue)
}
if err := debit(ctx, tx, userID, price); err != nil { if err := debit(ctx, tx, userID, price); err != nil {
return err return err
} }
kind := cfg().Moons[mrand.IntN(len(cfg().Moons))] // Names the player hasn't used yet (the row lock above keeps them so).
// A name the player hasn't used yet (the row lock above keeps it so).
rows, err := tx.Query(ctx, "select name from staking_source where user_id = $1", userID) rows, err := tx.Query(ctx, "select name from staking_source where user_id = $1", userID)
if err != nil { if err != nil {
return err return err
@@ -782,52 +812,67 @@ func buyMoon(ctx context.Context, db *pgxpool.Pool, userID, grade string) (strin
if err != nil { if err != nil {
return err return err
} }
name := rollMoonName(cfg().MoonNames, mrand.Float64) best, bestRating := -1, 0
for i := 2; slices.Contains(taken, name); i++ { for n := range count {
if i <= 20 { kind := cfg().Moons[mrand.IntN(len(cfg().Moons))]
name = rollMoonName(cfg().MoonNames, mrand.Float64) name := rollMoonName(cfg().MoonNames, mrand.Float64)
} else { for i := 2; slices.Contains(taken, name); i++ {
name = fmt.Sprintf("%s %d", cfg().MoonNames.Roots[0], i) if i <= 20 {
name = rollMoonName(cfg().MoonNames, mrand.Float64)
} else {
name = fmt.Sprintf("%s %d", cfg().MoonNames.Roots[0], i)
}
} }
} taken = append(taken, name)
err = tx.QueryRow(ctx, ` var moonID string
insert into staking_source(user_id, name, kind, grade, description, address) err = tx.QueryRow(ctx, `
values ($1, $2, $3, $4, $5, $6) returning id`, insert into staking_source(user_id, name, kind, grade, description, address)
userID, name, kind.Name, g.Grade, kind.Description, "0x"+randomHex(8)).Scan(&moonID) values ($1, $2, $3, $4, $5, $6) returning id`,
if err != nil { userID, name, kind.Name, g.Grade, kind.Description, "0x"+randomHex(8)).Scan(&moonID)
return err
}
wells := rollWells(cfg(), g, kind, owned == 0, mrand.Float64)
rating := moonRating(g, wells)
for _, w := range wells {
tag, err := tx.Exec(ctx, `
insert into resource_well(source_id, resource_id, supply)
select $1, id, $3 from resource where name = $2`,
moonID, w.Resource, w.Supply)
if err != nil { if err != nil {
return err return err
} }
if tag.RowsAffected() != 1 {
return fmt.Errorf("no resource %q for a well", w.Resource) wells := rollWellsAtLeast(cfg(), g, kind, owned+n == 0, mrand.Float64, floor)
rating := moonRating(g, wells)
for _, w := range wells {
tag, err := tx.Exec(ctx, `
insert into resource_well(source_id, resource_id, supply)
select $1, id, $3 from resource where name = $2`,
moonID, w.Resource, w.Supply)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("no resource %q for a well", w.Resource)
}
}
// Describe the moon from its look and wells (moondesc.go).
desc, err := moonDescription(ctx, tx, moonID, g.MeanSupply())
if err != nil {
return err
}
_, err = tx.Exec(ctx, "update staking_source set description = $1, rating = $3 where id = $2",
desc, moonID, rating)
if err != nil {
return err
}
ev := map[string]any{"moon": moonID, "price": price / float64(count), "rating": rating, "grade": g.Grade}
if pack != nil {
ev["pack"] = count
}
if err := logEvent(ctx, tx, userID, "buy_moon", ev); err != nil {
return err
}
ids = append(ids, moonID)
if best < 0 || rating > bestRating {
best, bestRating = len(ids)-1, rating
} }
} }
// Describe the moon from its look and wells (moondesc.go). ids[0], ids[best] = ids[best], ids[0]
desc, err := moonDescription(ctx, tx, moonID, g.MeanSupply()) return unlockRefineryAt(ctx, tx, userID, g.Grade)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "update staking_source set description = $1, rating = $3 where id = $2",
desc, moonID, rating)
if err != nil {
return err
}
if err := unlockRefineryAt(ctx, tx, userID, g.Grade); err != nil {
return err
}
return logEvent(ctx, tx, userID, "buy_moon", map[string]any{"moon": moonID, "price": price, "rating": rating, "grade": g.Grade})
}) })
return moonID, err return ids, err
} }
func buyTool(ctx context.Context, db *pgxpool.Pool, userID string, storeItemID int) (string, error) { func buyTool(ctx context.Context, db *pgxpool.Pool, userID string, storeItemID int) (string, error) {
+18 -2
View File
@@ -36,6 +36,15 @@ type GradeOffer struct {
SupplyMin float64 // a well holds from SupplyMin to just under SupplyMax SupplyMin float64 // a well holds from SupplyMin to just under SupplyMax
SupplyMax float64 SupplyMax float64
Default bool // what a buy without a grade gets Default bool // what a buy without a grade gets
Packs []PackOffer
}
// PackOffer is a moon pack of a grade (config moonPacks), once the store is open.
type PackOffer struct {
Count int
Price float64
Discount float64
CanAfford bool
} }
// GradeOffers lists every moon grade, cheapest first. // GradeOffers lists every moon grade, cheapest first.
@@ -44,9 +53,16 @@ func (s *State) GradeOffers() []GradeOffer {
var out []GradeOffer var out []GradeOffer
for _, g := range cfg().MoonGrades { for _, g := range cfg().MoonGrades {
lo, hi := g.wellCountRange() lo, hi := g.wellCountRange()
out = append(out, GradeOffer{Grade: g.Grade, Price: g.Price, CanAfford: s.Balance >= g.Price, o := GradeOffer{Grade: g.Grade, Price: g.Price, CanAfford: s.Balance >= g.Price,
WellsMin: lo, WellsMax: hi, SupplyMin: g.WellSupplyMin, SupplyMax: g.WellSupplyMax, WellsMin: lo, WellsMax: hi, SupplyMin: g.WellSupplyMin, SupplyMax: g.WellSupplyMax,
Default: g.Grade == def}) Default: g.Grade == def}
if s.Unlocked("store") {
for _, p := range cfg().MoonPacks {
price := p.PackPrice(&g)
o.Packs = append(o.Packs, PackOffer{Count: p.Count, Price: price, Discount: p.Discount, CanAfford: s.Balance >= price})
}
}
out = append(out, o)
} }
return out return out
} }
+12 -3
View File
@@ -346,9 +346,18 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
switch action { switch action {
case "refused": case "refused":
case "buy-moon": case "buy-moon":
var moonID string count := 1
if moonID, err = buyMoon(ctx, s.db, userID, get("grade")); err == nil { if c := get("count"); c != "" {
message, view = "New moon claimed!", View{Section: "moons", Moon: moonID} if count, err = strconv.Atoi(c); err != nil {
count = 0 // (refused: no pack of that size)
}
}
var ids []string
if ids, err = buyMoons(ctx, s.db, userID, get("grade"), count); err == nil {
message, view = "New moon claimed!", View{Section: "moons", Moon: ids[0]}
if len(ids) > 1 {
message = fmt.Sprintf("%d new moons claimed!", len(ids))
}
} }
case "sell-moon": case "sell-moon":
var name string var name string