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).
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/.
# Needs MM_API (a test server): it makes a new player there.
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:
rm -rf build dist .internal/cache
+19 -1
View File
@@ -1015,9 +1015,27 @@ function M:batch_ready(g, jobs)
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)
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
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.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.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
-- 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
-- count goes up (the server's LastWell).
M.register("claim", function(st, p, ctx)
if p["claim-wide"] then
return false
end
local list = {}
for _, s in ipairs(st.stakes) do
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))
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then
if wide then
return false -- multi-well drills: the server splits them
end
local scope
if p["claim-wide"] then
scope = wide and s.moonId == p["claim-wide"]
else
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
end
end
@@ -600,14 +600,33 @@ M.register("claim", function(st, p, ctx)
return false
end
-- 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)
local emptied = {}
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)))
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
local moon = find(st.moons, s.moonId)
if (s.reach or 1) > 1 and moon then
local sup = {}
for i, w in ipairs(moon.wells or {}) do
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
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
end
@@ -633,7 +652,7 @@ M.register("claim", function(st, p, ctx)
end
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).
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)
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
@@ -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
end
local tool = M.idle_copy(st, p.tool)
if not tool or (tool.reach or 1) > 1 then
if not tool then
return false
end
local group = nil
@@ -677,7 +696,7 @@ M.register("stake", function(st, p, ctx)
end
-- 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)
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
end
M.add_stake(st, tool, moon.id, w, ctx, group)
+1 -1
View File
@@ -605,7 +605,7 @@ function update(self, dt)
end
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
self.dragging = 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 moon = section == "moons" and st.moon or nil
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)
viewport(g, root, pad, pad, vp, shown_moon(g))
-- 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.reveal: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)
check_input(g)
tooltip.update(g.dialog:is_open())
+18 -10
View File
@@ -167,8 +167,7 @@ local function geometry(inner)
return {
wide = wide,
gap = wide and 12 or 10,
media_w = wide and 48 or 40, media_h = wide and 40 or 34,
status = wide and math.min(230, math.floor(inner * 0.22)) or 0,
status = wide and math.min(200, math.floor(inner * 0.16)) or 0,
track = wide and 124 or 90,
sell = wide and 150 or 60,
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)
tooltip.add(n, t.name .. ": yield level " .. (t.yieldLevel or 0) .. ", cycle level " .. (t.cycleLevel or 0))
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 = {
row({ height = LINE1, gap = gap, align = "center" }, {
made({ width = geo.media_w, height = geo.media_h, shrink = 0 }, function(p, x, y)
local mw, mh = geo.media_w, geo.media_h
local m = ui.rect(p, x, y, mw, mh, "bg")
ui.outline(m, 0, 0, mw, mh, "line")
cards.image(m, t.image, (mw - mh) / 2, 0, mh)
end),
col({ grow = 1, shrink = 1, basis = 0 }, {
name,
fg.label(c, tagline, { font = "mono", px = 12, color = "muted" }, 16, { fit = true }),
@@ -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] = row({ height = 20, align = "center" }, { left })
end
local rh = row_h(open, wide)
local selected = t.selected and self.view.drill ~= ""
return col({ width = w, height = rh, padding = { PADY, PADX, PADY, PADX } }, lines, { make = function(p, x, y, bw, bh)
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 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()
@@ -576,6 +579,11 @@ local function ladder(st, c, tr, inner)
return col({}, { box({}, { head, rate_head }), col({ gap = 1 }, rows), space(0, 11) })
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)
local st = self.state
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")
return 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
local picker = panel_box({ width = inner, padding = { 14, 0, 6, 0 } }, {
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
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")"
log=$(dirname "$out")/shot.log
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).
- `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`,
`tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds
flat, with a price and, higher up, refined `parts`. Levels are per tool
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
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
+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
(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 /
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
+1 -1
View File
@@ -74,7 +74,7 @@ Core-loop actions and their params:
| 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`) |
| `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) |
+15 -1
View File
@@ -851,13 +851,27 @@ type APIMoonGrade struct {
SupplyMax float64 `json:"supplyMax"`
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
// 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 {
out := []APIMoonGrade{}
for _, g := range gs {
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 {
a.WhyText = "Not enough credits"
}
+35
View File
@@ -13,6 +13,7 @@ import (
"encoding/json"
"fmt"
"log"
"math"
"slices"
"strconv"
"strings"
@@ -30,6 +31,8 @@ type GameConfig struct {
StartingTools []int `json:"startingTools"` // store item ids, given free
// Moon vendors, cheapest first (rollWells in game.go); the first is the default grade.
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).
MoonResale MoonResale `json:"moonResale"`
// Selling tools back (resale.go).
@@ -68,6 +71,30 @@ type MoonGrade struct {
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.
func (cfg *GameConfig) grade(name string) *MoonGrade {
if i := cfg.gradeIndex(name); i >= 0 {
@@ -162,6 +189,14 @@ func (cfg *GameConfig) validate() error {
if err := cfg.MarketPressure.validate(); err != nil {
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 {
return err
}
+49 -47
View File
@@ -3,7 +3,7 @@
"startingResources": 0,
"startingTools": [1],
"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}},
{"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}},
@@ -14,6 +14,8 @@
{"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}}
],
"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},
"equipmentResale": {"factor": 0.5},
"marketVolatility": 0.25,
@@ -33,20 +35,20 @@
"image": "shovel.png",
"price": 1200,
"claimAmount": 8,
"completionTimeInSecs": 3,
"completionTimeInSecs": 3, "reach": 5,
"tracks": {
"yield": {"name": "Twin rotors", "levels": [
{"value": 10, "price": 280},
{"value": 12, "price": 280},
{"value": 14, "price": 290, "parts": {"Dusk Alloy": 1}},
{"value": 16, "price": 280, "parts": {"Dusk Alloy": 2}}
{"value": 14, "price": 290},
{"value": 16, "price": 280}
]},
"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}}
{"value": 1.9, "price": 250},
{"value": 1.7, "price": 210},
{"value": 1.5, "price": 270}
]}
}
},
@@ -57,21 +59,21 @@
"image": "pickaxe.png",
"price": 8000,
"claimAmount": 120,
"completionTimeInSecs": 6,
"completionTimeInSecs": 6, "reach": 5,
"tracks": {
"yield": {"name": "Wider chisel", "levels": [
{"value": 144, "price": 1520},
{"value": 168, "price": 1520},
{"value": 192, "price": 1520, "parts": {"Dusk Alloy": 2}},
{"value": 216, "price": 1520, "parts": {"Cryo Cell": 1}},
{"value": 240, "price": 1520, "parts": {"Plasma Weave": 1}}
{"value": 192, "price": 1520},
{"value": 216, "price": 1520},
{"value": 240, "price": 1520}
]},
"cycle": {"name": "Pneumatic hammer", "levels": [
{"value": 5.2, "price": 1160},
{"value": 4.5, "price": 1360},
{"value": 3.9, "price": 1560, "parts": {"Dusk Alloy": 2}},
{"value": 3.4, "price": 1720, "parts": {"Cryo Cell": 1}},
{"value": 3, "price": 1780, "parts": {"Plasma Weave": 1}}
{"value": 3.9, "price": 1560},
{"value": 3.4, "price": 1720},
{"value": 3, "price": 1780}
]}
}
},
@@ -82,21 +84,21 @@
"image": "lunarlite.jpg",
"price": 40200,
"claimAmount": 900,
"completionTimeInSecs": 8,
"completionTimeInSecs": 8, "reach": 5,
"tracks": {
"yield": {"name": "Wide-bore bit", "levels": [
{"value": 1060, "price": 6900},
{"value": 1270, "price": 8700},
{"value": 1430, "price": 6900, "parts": {"Cryo Cell": 1}},
{"value": 1640, "price": 8700, "parts": {"Plasma Weave": 2}},
{"value": 1800, "price": 6900, "parts": {"Plasma Weave": 3}}
{"value": 1430, "price": 6900},
{"value": 1640, "price": 8700},
{"value": 1800, "price": 6900}
]},
"cycle": {"name": "Spindle motor", "levels": [
{"value": 7, "price": 5400},
{"value": 6, "price": 7200},
{"value": 5.2, "price": 7800, "parts": {"Cryo Cell": 1}},
{"value": 4.6, "price": 7800, "parts": {"Plasma Weave": 2}},
{"value": 4, "price": 9900, "parts": {"Plasma Weave": 3}}
{"value": 5.2, "price": 7800},
{"value": 4.6, "price": 7800},
{"value": 4, "price": 9900}
]}
}
},
@@ -108,21 +110,21 @@
"price": 645000,
"claimAmount": 20000,
"completionTimeInSecs": 10,
"reach": 2,
"reach": 5,
"tracks": {
"yield": {"name": "Wider beam", "levels": [
{"value": 23700, "price": 115000},
{"value": 28200, "price": 135000},
{"value": 31800, "price": 115000, "parts": {"Cryo Cell": 2}},
{"value": 36300, "price": 135000, "parts": {"Plasma Weave": 3}},
{"value": 40000, "price": 115000, "parts": {"Void Core": 1}}
{"value": 31800, "price": 115000},
{"value": 36300, "price": 135000},
{"value": 40000, "price": 115000}
]},
"cycle": {"name": "Laser capacitors", "levels": [
{"value": 8.7, "price": 90000},
{"value": 7.5, "price": 115000},
{"value": 6.5, "price": 125000, "parts": {"Cryo Cell": 2}},
{"value": 5.7, "price": 130000, "parts": {"Plasma Weave": 3}},
{"value": 5, "price": 150000, "parts": {"Void Core": 1}}
{"value": 6.5, "price": 125000},
{"value": 5.7, "price": 130000},
{"value": 5, "price": 150000}
]}
}
},
@@ -134,21 +136,21 @@
"price": 9440000,
"claimAmount": 384000,
"completionTimeInSecs": 12,
"reach": 3,
"reach": 5,
"tracks": {
"yield": {"name": "Fine sieves", "levels": [
{"value": 452000, "price": 1600000},
{"value": 542000, "price": 2080000},
{"value": 610000, "price": 1600000, "parts": {"Plasma Weave": 3}},
{"value": 700000, "price": 2080000, "parts": {"Void Core": 1}},
{"value": 768000, "price": 1600000, "parts": {"Stellar Lattice": 1}}
{"value": 610000, "price": 1600000},
{"value": 700000, "price": 2080000},
{"value": 768000, "price": 1600000}
]},
"cycle": {"name": "Auger gearbox", "levels": [
{"value": 10.4, "price": 1360000},
{"value": 9, "price": 1600000},
{"value": 7.8, "price": 1840000, "parts": {"Plasma Weave": 3}},
{"value": 6.8, "price": 2000000, "parts": {"Void Core": 1}},
{"value": 6, "price": 2080000, "parts": {"Stellar Lattice": 1}}
{"value": 7.8, "price": 1840000},
{"value": 6.8, "price": 2000000},
{"value": 6, "price": 2080000}
]}
}
},
@@ -165,16 +167,16 @@
"yield": {"name": "Larger plates", "levels": [
{"value": 1050000, "price": 3120000},
{"value": 1220000, "price": 3120000},
{"value": 1420000, "price": 3600000, "parts": {"Void Core": 1}},
{"value": 1590000, "price": 3120000, "parts": {"Stellar Lattice": 1}},
{"value": 1760000, "price": 3120000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
{"value": 1420000, "price": 3600000},
{"value": 1590000, "price": 3120000},
{"value": 1760000, "price": 3120000}
]},
"cycle": {"name": "Charge coils", "levels": [
{"value": 12.2, "price": 2400000},
{"value": 10.5, "price": 2960000},
{"value": 9.1, "price": 3280000, "parts": {"Void Core": 1}},
{"value": 8, "price": 3440000, "parts": {"Stellar Lattice": 1}},
{"value": 7, "price": 4000000, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
{"value": 9.1, "price": 3280000},
{"value": 8, "price": 3440000},
{"value": 7, "price": 4000000}
]}
}
},
@@ -191,16 +193,16 @@
"yield": {"name": "Core barrel", "levels": [
{"value": 1910000, "price": 4480000},
{"value": 2250000, "price": 5040000},
{"value": 2550000, "price": 4480000, "parts": {"Void Core": 2}},
{"value": 2890000, "price": 5040000, "parts": {"Stellar Lattice": 2}},
{"value": 3200000, "price": 4480000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
{"value": 2550000, "price": 4480000},
{"value": 2890000, "price": 5040000},
{"value": 3200000, "price": 4480000}
]},
"cycle": {"name": "Drill string", "levels": [
{"value": 14, "price": 3360000},
{"value": 12, "price": 4480000},
{"value": 10.4, "price": 4800000, "parts": {"Void Core": 2}},
{"value": 9.1, "price": 5200000, "parts": {"Stellar Lattice": 2}},
{"value": 8, "price": 5680000, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
{"value": 10.4, "price": 4800000},
{"value": 9.1, "price": 5200000},
{"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
// base prices (at most 64 times; then the best roll).
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)
if g.MinValue <= 0 {
if minValue <= 0 {
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++ {
w := rollWellsOnce(c, g, kind, first, rnd)
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,
// or the lowest.
func buyMoon(ctx context.Context, db *pgxpool.Pool, userID, grade string) (string, error) {
var moonID string
if grade != "" && cfg().gradeIndex(grade) < 0 {
return "", gameErr("Unknown moon grade")
ids, err := buyMoons(ctx, db, userID, grade, 1)
if err != nil {
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 {
ids = nil
// 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 {
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 {
return err
}
if pack != nil && !(&State{Onboarding: onboarding}).Unlocked("store") {
return gameErr("Not available yet")
}
var owned int
var last string
err = tx.QueryRow(ctx, `
@@ -767,13 +795,15 @@ func buyMoon(ctx context.Context, db *pgxpool.Pool, userID, grade string) (strin
grade = last
}
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 {
return err
}
kind := cfg().Moons[mrand.IntN(len(cfg().Moons))]
// A name the player hasn't used yet (the row lock above keeps it so).
// Names the player hasn't used yet (the row lock above keeps them so).
rows, err := tx.Query(ctx, "select name from staking_source where user_id = $1", userID)
if err != nil {
return err
@@ -782,52 +812,67 @@ func buyMoon(ctx context.Context, db *pgxpool.Pool, userID, grade string) (strin
if err != nil {
return err
}
name := rollMoonName(cfg().MoonNames, mrand.Float64)
for i := 2; slices.Contains(taken, name); i++ {
if i <= 20 {
name = rollMoonName(cfg().MoonNames, mrand.Float64)
} else {
name = fmt.Sprintf("%s %d", cfg().MoonNames.Roots[0], i)
best, bestRating := -1, 0
for n := range count {
kind := cfg().Moons[mrand.IntN(len(cfg().Moons))]
name := rollMoonName(cfg().MoonNames, mrand.Float64)
for i := 2; slices.Contains(taken, name); i++ {
if i <= 20 {
name = rollMoonName(cfg().MoonNames, mrand.Float64)
} else {
name = fmt.Sprintf("%s %d", cfg().MoonNames.Roots[0], i)
}
}
}
err = tx.QueryRow(ctx, `
insert into staking_source(user_id, name, kind, grade, description, address)
values ($1, $2, $3, $4, $5, $6) returning id`,
userID, name, kind.Name, g.Grade, kind.Description, "0x"+randomHex(8)).Scan(&moonID)
if err != nil {
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)
taken = append(taken, name)
var moonID string
err = tx.QueryRow(ctx, `
insert into staking_source(user_id, name, kind, grade, description, address)
values ($1, $2, $3, $4, $5, $6) returning id`,
userID, name, kind.Name, g.Grade, kind.Description, "0x"+randomHex(8)).Scan(&moonID)
if err != nil {
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).
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
}
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})
ids[0], ids[best] = ids[best], ids[0]
return unlockRefineryAt(ctx, tx, userID, g.Grade)
})
return moonID, err
return ids, err
}
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
SupplyMax float64
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.
@@ -44,9 +53,16 @@ func (s *State) GradeOffers() []GradeOffer {
var out []GradeOffer
for _, g := range cfg().MoonGrades {
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,
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
}
+12 -3
View File
@@ -346,9 +346,18 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
switch action {
case "refused":
case "buy-moon":
var moonID string
if moonID, err = buyMoon(ctx, s.db, userID, get("grade")); err == nil {
message, view = "New moon claimed!", View{Section: "moons", Moon: moonID}
count := 1
if c := get("count"); c != "" {
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":
var name string