Compare commits

...
9 Commits
Author SHA1 Message Date
JosephFeranoandClaude Opus 5.5 7e18f99796 Redrill sends the smallest drill that finishes the well's remaining room alone, else the biggest and again (two 4s and a 20 on a well with 3 left: a 4); the client predicts the same
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:49:40 +07:00
JosephFeranoandClaude Opus 5.5 eda38810cd Refinery unlocks with the first C moon (was B)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:30:57 +07:00
JosephFeranoandClaude Opus 5.5 91c71fd9c8 Balance: no duds early: D and C moons are never worth under 1.3x their price (minValue re-rolls the poorer ones), with wells 30% smaller; B and up keep their duds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:02:46 +07:00
JosephFerano 55f28caa14 Merge branch 'drill-onboard' 2026-10-22 09:41:47 +07:00
JosephFeranoandClaude Opus 5.5 71dc1d1b1d Equipment: a tool card opens its drill panel as soon as Equipment is unlocked (onboarding); the panel's Store parts (upgrade, slots, spares, sale) wait for the Store
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:41:47 +07:00
JosephFeranoandClaude Opus 5.5 2e6872a824 Balance: moons cost twice as much (D 300 ... S 3M) and their wells hold 2.05x (worth about 2.5% more for the price); sim: C 23 m, B 37 m, A 1 h 08, S 2 h 52-2 h 58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:38:12 +07:00
JosephFeranoandClaude Opus 5.5 fa18e8c1ed Balance: drill cycles 3-16 s (PD-1 3, PX-4 6, L100 8, L300 10, LRE 12, EDE 14, LCE 16) with the same claim sizes: income 2-4x, progression about twice as fast in the sim
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:36:08 +07:00
JosephFeranoandClaude Opus 5.5 a199fdce0c Client: moon list rows put the grade and rating chips on the second line, before the status, so they don't run into the ready badge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:34:04 +07:00
JosephFeranoandClaude Opus 5.5 5bc0a3deab Balance: C moons cost 3000 with wells of 150-300 (value unchanged at ~1.7x), so the starting 1500 no longer buys one; first C at ~35 min in the sim
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:32:31 +07:00
17 changed files with 352 additions and 73 deletions

No files matched your search

+26 -7
View File
@@ -699,21 +699,40 @@ M.register("redrill", function(st, p, ctx)
if #tools == 0 then
return false
end
-- Until the well has no room left (the server leaves the rest idle).
local staked = 0
for _, t in ipairs(tools) do
local group = nil
-- Until the well has no room left, like the server's redrill (game.go redrillPick):
-- the smallest drill that fills what is left alone, else the biggest and again; the
-- rest stay idle.
local function group_of(t)
if st.well and st.well.id == id then
for _, g in ipairs(st.well.idle or {}) do
if g.storeItemId == t.storeItemId and g.tier == t.tier then
group = g
return g
end
end
end
local amount = group and group.yield or t.yield * yield_boost(st, w.resource)
if (M.room(st, id, amount, 1) or 0) <= EPS then
end
local staked = 0
while #tools > 0 do
local left = M.room(st, id, 1e18, 1) or 0
if left <= EPS then
break
end
local pick
for i, t in ipairs(tools) do
if t.yield >= left - EPS and (not pick or t.yield < tools[pick].yield) then
pick = i
end
end
if not pick then
pick = 1
for i, t in ipairs(tools) do
if t.yield > tools[pick].yield then
pick = i
end
end
end
local t = table.remove(tools, pick)
local group = group_of(t)
M.add_stake(st, t, moon.id, w, ctx, group)
staked = staked + 1
end
+14 -6
View File
@@ -4,6 +4,8 @@
-- upgrade button with its parts. A card opens the drill panel (view drill=<tool id>) in
-- the aside: stats, upgrade, tier ladder, slots (unequip), spare attachments (equip, with
-- the stats they'd give; sell), idle copies and their best well, and selling the drill.
-- Before the Store is unlocked (onboarding) cards and panel leave out the Store's parts
-- (attachments, upgrades, selling): the panel is stats, tiers and idle copies.
local theme = require("modules.theme")
local ui = require("modules.ui")
@@ -205,6 +207,10 @@ local function tool_card(self, H, st, c, t, open)
ui.rect(p, 0, 0, bw, 1, "line")
return 6 + stats(st, p, t, 0, 4, bw) + 4
end)
c.id = "drill:" .. t.id
c.click = function()
H.navigate(self, drill_view(self, t.id))
end
if open then
-- Attachments: slot pips over the image's corner, tags under the stats.
local holder = ui.rect(c.root, 6, 6, 1, 22, "panel")
@@ -223,10 +229,6 @@ local function tool_card(self, H, st, c, t, open)
end
end)
end
c.id = "drill:" .. t.id
c.click = function()
H.navigate(self, drill_view(self, t.id))
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 fh = upgrade(self, { action = function() end }, st, probe, t, 0, 0, c.bw, true)
@@ -344,6 +346,7 @@ function M.aside(self, root, w, h, H)
if not t then
return false
end
local open = st.unlocked.store -- upgrade, attachments, selling (onboarding.go)
local pad = 18
local inner = w - pad * 2
local area = scroll.area(self, root, 0, 2, w, h - 4, "drill")
@@ -368,7 +371,7 @@ function M.aside(self, root, w, h, H)
fg.label(c, "T" .. t.tier, { font = "mono", px = 13, color = "muted" }, 20),
d.mining and space(0, 4) or nil,
d.mining and fg.measured(c, function(p, x, y, bw)
return cards.para(p, "Mining now. Changes apply from its next drill.", { font = "sans", px = 13, color = "muted" }, 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)
end) or nil,
}),
fg.button(c, "×", { small = true, w = 32 }, function(b)
@@ -379,7 +382,7 @@ function M.aside(self, root, w, h, H)
-- Stats and the upgrade.
add(col({ padding = { 4, 0, 6, 0 } }, { rule(), stats_box(st, c, t) }))
local u = t.preview
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" }
@@ -391,8 +394,10 @@ function M.aside(self, root, w, h, H)
}))
add(space(0, 6))
end
if open then
add(space(0, 4))
add(upgrade_box(self, H, st, c, t, inner, false))
end
add(space(0, 8))
-- Tier ladder.
@@ -426,6 +431,8 @@ function M.aside(self, root, w, h, H)
}))
end
-- The Store's parts: slots, spare attachments, selling the drill.
if open then
-- Slots.
add(h3(c, "Slots"))
local slots = {}
@@ -565,6 +572,7 @@ function M.aside(self, root, w, h, H)
end),
}),
}))
end
local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, pad, pad), c)
+64
View File
@@ -0,0 +1,64 @@
"""The drill panel during onboarding (needs --fixture-file of a plain player:
scripts/fixture.py <api> --plain, and an MM_DEV=1 server): once the first deploy reveals
Equipment, a tool card opens its panel (stats, tiers) without the Store's parts (upgrade,
slots, spares, Sell drill); after the first claim and sale reveal the Store, the same panel
has them. Shots "before" and "after", each linted."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from lists import admin, call # noqa: E402
def labels(game):
"""Every shown text on screen, lower case."""
return {(n.get("text") or "").lower() for n in game.dump()["nodes"] if n.get("text")}
def open_panel(game, tool):
game.navigate("{section='equipment'}")
game.idle()
game.click("drill:" + tool)
game.wait_for("g.view.drill ~= '' and g.state.equipment.drill ~= nil", 10)
game.idle()
def run(game, args):
game.idle()
admin(game, "credits", amount=5000, op="grant")
r = call(game, "POST", "/api/act/buy-moon", {})
game.expect(r.get("ok"), "buy-moon: %s" % r.get("error"))
st = r["state"]
tool = st["tools"][0]["id"]
well = st["moon"]["wells"][0]["id"]
r = call(game, "POST", "/api/act/stake", {"tool": tool, "target": well})
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
u = r["state"]["unlocked"]
game.expect(u["equipment"] and not u["store"], "after the first deploy: %s" % u)
open_panel(game, tool)
game.sleep(2) # (the wallet counts down from the moon purchase)
game.shot("before")
game.lint("before")
texts = labels(game)
game.expect("tiers" in texts, "tier ladder shown")
for t in ("slots", "spare attachments", "sell drill"):
game.expect(t not in texts, "%s hidden before the Store" % t)
game.expect(not any(t.startswith("upgrade to") for t in texts), "no upgrade before the Store")
# First claim (Market), first sale (Store).
admin(game, "finish-stakes")
r = call(game, "POST", "/api/act/claim", {"claim-well": well})
game.expect(r.get("ok"), "claim: %s" % r.get("error"))
res = [x for x in r["state"]["resources"] if x["amount"] > 0][0]["name"]
r = call(game, "POST", "/api/act/sell", {"resource": res, "all": "1"})
game.expect(r.get("ok"), "sell: %s" % r.get("error"))
game.expect(r["state"]["unlocked"]["store"], "store unlocked after the sale")
open_panel(game, tool)
game.sleep(3) # (the sale's effects and the wallet)
game.shot("after")
game.lint("after")
texts = labels(game)
for t in ("tiers", "slots", "spare attachments", "sell drill"):
game.expect(t in texts, "%s shown after the Store" % t)
+15 -16
View File
@@ -46,20 +46,25 @@ function M:init(moon, on_select, opts)
-- The name may hold a Greek letter (sym font): drawn as a label at the name's place.
local p = gui.get_position(name)
gui.set_enabled(name, false)
local _, name_w = ui.label(self.content, moon.name, "moon", p.x, -p.y)
ui.label(self.content, moon.name, "moon", p.x, -p.y)
-- The second line: grade and rating chips, then the status ("1 drill"); the name has
-- the first line to itself and the ready badge the right side.
local sp = gui.get_position(sub)
local cx = sp.x
if (moon.grade or "") ~= "" then
local gb = chip.grade(self.content, moon.grade, p.x + name_w + 8, -p.y)
local gb = chip.grade(self.content, moon.grade, cx, -sp.y)
tooltip.add(gb, "Grade " .. moon.grade .. " moon")
name_w = name_w + 8 + chip.GRADE_W
cx = cx + chip.GRADE_W + 6
end
if moon.rating then
-- The web's .rating-chip: outlined, in the tier's color; exceptional filled, glowing.
local tier = moon.rating.tier
local color = TIER[tier] or "line_hi"
local box = chip.new(self.content, "★" .. moon.rating.stars, p.x + name_w + 8, -p.y, {
local box, bw = chip.new(self.content, "★" .. moon.rating.stars, cx, -sp.y, {
color = color, alpha = tier == "good" and 0.5 or nil, text = TIER[tier] or "muted",
font = "sym", px = 10, h = 16, pad = 4, fill = tier == "exceptional" and 0.15 or nil,
})
cx = cx + bw + 6
self.chip = { box }
if tier == "exceptional" then
local bs, bp = gui.get_size(box), gui.get_position(box)
@@ -95,14 +100,17 @@ function M:init(moon, on_select, opts)
status = moon.drills > 0 and ui.plural(moon.drills, "drill") or (dry and "Tapped out" or "Idle")
end
ui.style_text(sub, "small", status)
if cx > sp.x then
cx = cx + 2 -- (a little more room before the text)
end
if moon.over and not moon.finished then
-- A well here has more drills than supply (the web's warn dot).
local sp = gui.get_position(sub)
local dot = ui.rect(self.content, sp.x, -sp.y - 4, 7, 7, "ready")
local dot = ui.rect(self.content, cx, -sp.y - 4, 7, 7, "ready")
gui.play_flipbook(dot, "sphere")
tooltip.add(dot, "A well here has more drills than supply")
gui.set_position(sub, vmath.vector3(sp.x + 12, sp.y, sp.z))
cx = cx + 12
end
gui.set_position(sub, vmath.vector3(cx, sp.y, sp.z))
-- (opts.ready: the count on our clock, core/ready)
local n_ready = opts.ready or moon.ready or 0
@@ -150,15 +158,6 @@ function M:set_size(w)
end
local badge = self:get_node("badge")
gui.set_position(badge, vmath.vector3(w - 10, -h / 2, 0))
if self.chip then
-- A narrow row: the rating chip goes rather than run into the ready badge.
local box = self.chip[1]
local right = gui.get_position(box).x + gui.get_size(box).x
local limit = w - 16 - (gui.is_enabled(badge) and gui.get_size(badge).x or 0)
for _, n in ipairs(self.chip) do
gui.set_enabled(n, right <= limit)
end
end
end
-- The veil's alpha at rest (dimmed, or lifted by hover / selection).
+1 -1
View File
@@ -114,7 +114,7 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
`all` for players from before it). New players see only the first moon purchase; deploying
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
attachments, moon sale, Redrill), buying a moon of `refinery.unlockGrade` (B) or higher Refinery, buying a
attachments, moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
+5 -3
View File
@@ -77,7 +77,7 @@ Core-loop actions and their params:
| `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 |
| `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 (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) |
| `sell` | `resource`, `amount` or `all=1` | resources and refined items |
The other sections' actions:
@@ -186,14 +186,16 @@ short code, listed with each field, and the web's text next to it.
`noSaleText` its text ("Claim its haul first").
- `bonus`: `{speed, yield}` from advisors and its attachments (capped); `gear` the
attachments `[{name, image, effect}]`.
- `drill` (with `drill=<tool id>`, else `null`): the drill panel `{toolId, slots: [{n,
- `drill` (with `drill=<tool id>`, once `unlocked.equipment`, else `null`): the drill panel `{toolId, slots: [{n,
attachment}], spares: [{name, count, cycleSecs, yield, canEquip, why, whyText, price, resale,
confirm}], mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo, ladder,
gear}`. `attachment` is `""` for an empty slot (unequip with `item=<name>&to=<toolId>`); a
spare's `cycleSecs`/`yield` are the drill's with it equipped, `why` is `fitted` (one is on
already) or `full`; `confirm` (panel) is the sell-tool dialog, a spare's the
sell-attachment one. `gear[].effectText` e.g. "+20% speed". While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy
and `deployTo` its best well (a suggestion, as in `buyModal`).
and `deployTo` its best well (a suggestion, as in `buyModal`). Its upgrade, slots, spares
and Sell drill are Store features (`upgrade`, `equip`, `unequip`, `sell-tool` are refused
before it): show them once `unlocked.store`; before that the panel is its stats and `ladder`.
### store (`s=store`)
+6 -1
View File
@@ -213,6 +213,10 @@ type DrillPanel struct {
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.
@@ -265,7 +269,8 @@ var noSaleText = map[string]string{
}
func (s *State) drillPanel(row ToolRow) *DrillPanel {
d := &DrillPanel{ToolRow: row, Mining: row.Stake != nil, Resale: row.Item.ToolResale(row.Tier)}
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}
+7
View File
@@ -62,6 +62,10 @@ type MoonGrade struct {
// How many wells its moons have: weight by count, e.g. {"2": 30, "5": 8}.
WellCountWeights map[int]float64 `json:"wellCountWeights"`
Affinities map[string]float64 `json:"affinities,omitempty"`
// The least a new moon is worth at base prices, × its price (0: no floor). The early
// grades have one so they never lose money (no duds while players learn); a roll under
// it is rolled again (rollWells).
MinValue float64 `json:"minValue,omitempty"`
}
// grade is the moon grade with that name, else the first (lowest) one.
@@ -239,6 +243,9 @@ func (cfg *GameConfig) validateWells() error {
if g.Grade == "" || cfg.gradeIndex(g.Grade) != i {
return fmt.Errorf("moonGrades: grade names must be set and unique (%q)", g.Grade)
}
if g.MinValue < 0 || g.MinValue > 3 {
return fmt.Errorf("moon grade %s: minValue must be in [0, 3] (× the price)", g.Grade)
}
if g.Price < 0 || g.WellSupplyMin < 0 || g.WellSupplyMin > g.WellSupplyMax {
return fmt.Errorf("moon grade %s: price and supply can't be negative, wellSupplyMin can't be above wellSupplyMax", g.Grade)
}
+13 -13
View File
@@ -3,15 +3,15 @@
"startingResources": 0,
"startingTools": [1],
"moonGrades": [
{"grade": "D", "price": 150, "wellSupplyMin": 16, "wellSupplyMax": 32, "wellCountWeights": {"1": 40, "2": 60},
{"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 23, "wellSupplyMax": 46, "wellCountWeights": {"1": 40, "2": 60},
"affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}},
{"grade": "C", "price": 1500, "wellSupplyMin": 75, "wellSupplyMax": 150, "wellCountWeights": {"2": 50, "3": 50},
{"grade": "C", "price": 6000, "minValue": 1.3, "wellSupplyMin": 216, "wellSupplyMax": 430, "wellCountWeights": {"2": 50, "3": 50},
"affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}},
{"grade": "B", "price": 15000, "wellSupplyMin": 360, "wellSupplyMax": 960, "wellCountWeights": {"2": 30, "3": 40, "4": 30},
{"grade": "B", "price": 30000, "wellSupplyMin": 738, "wellSupplyMax": 1968, "wellCountWeights": {"2": 30, "3": 40, "4": 30},
"affinities": {"Sollux": 1, "Shadowstone": 1.5, "Azurium": 2, "Novafor": 2, "Nebulance": 1.2}},
{"grade": "A", "price": 150000, "wellSupplyMin": 2400, "wellSupplyMax": 6300, "wellCountWeights": {"3": 30, "4": 40, "5": 30},
{"grade": "A", "price": 300000, "wellSupplyMin": 4920, "wellSupplyMax": 12915, "wellCountWeights": {"3": 30, "4": 40, "5": 30},
"affinities": {"Sollux": 0.6, "Shadowstone": 1, "Azurium": 1.5, "Novafor": 2, "Nebulance": 2}},
{"grade": "S", "price": 1500000, "wellSupplyMin": 20600, "wellSupplyMax": 50600, "wellCountWeights": {"4": 50, "5": 50},
{"grade": "S", "price": 3000000, "wellSupplyMin": 42230, "wellSupplyMax": 103730, "wellCountWeights": {"4": 50, "5": 50},
"affinities": {"Sollux": 0.4, "Shadowstone": 0.6, "Azurium": 1, "Novafor": 2, "Nebulance": 3.5}}
],
"moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600},
@@ -33,7 +33,7 @@
"image": "shovel.png",
"price": 1200,
"claimAmount": 4,
"completionTimeInSecs": 12,
"completionTimeInSecs": 3,
"upgrades": [
{"tier": 1, "price": 80, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 100, "claimBoost": 0, "speed": 1.25},
@@ -49,7 +49,7 @@
"image": "pickaxe.png",
"price": 5700,
"claimAmount": 20,
"completionTimeInSecs": 12,
"completionTimeInSecs": 6,
"upgrades": [
{"tier": 1, "price": 400, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 470, "claimBoost": 0, "speed": 1.25},
@@ -65,7 +65,7 @@
"image": "lunarlite.jpg",
"price": 27000,
"claimAmount": 110,
"completionTimeInSecs": 13,
"completionTimeInSecs": 8,
"upgrades": [
{"tier": 1, "price": 1700, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 4500, "claimBoost": 0, "speed": 1.25},
@@ -81,7 +81,7 @@
"image": "lunarlite300.png",
"price": 290000,
"claimAmount": 1350,
"completionTimeInSecs": 14,
"completionTimeInSecs": 10,
"reach": 2,
"upgrades": [
{"tier": 1, "price": 37000, "claimBoost": 0, "speed": 1.12},
@@ -98,7 +98,7 @@
"image": "regolith.jpg",
"price": 3200000,
"claimAmount": 17000,
"completionTimeInSecs": 15,
"completionTimeInSecs": 12,
"reach": 3,
"upgrades": [
{"tier": 1, "price": 380000, "claimBoost": 0, "speed": 1.12},
@@ -115,7 +115,7 @@
"image": "dustexcavator.png",
"price": 7400000,
"claimAmount": 36000,
"completionTimeInSecs": 13,
"completionTimeInSecs": 14,
"reach": 5,
"upgrades": [
{"tier": 1, "price": 470000, "claimBoost": 0, "speed": 1.12},
@@ -132,7 +132,7 @@
"image": "lunarcoreextractor.png",
"price": 11000000,
"claimAmount": 47000,
"completionTimeInSecs": 11,
"completionTimeInSecs": 16,
"reach": 5,
"upgrades": [
{"tier": 1, "price": 840000, "claimBoost": 0, "speed": 1.12},
@@ -205,7 +205,7 @@
{ "maxBatch": 2500, "price": 15000000 }
],
"bulkLog": 1.83,
"unlockGrade": "B",
"unlockGrade": "C",
"items": [
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 40, "seconds": 1,
"inputs": {"Sollux": 2, "Shadowstone": 2},
+94 -11
View File
@@ -712,12 +712,29 @@ func createUser(ctx context.Context, db *pgxpool.Pool, name string) (string, err
return userID, err
}
// rollWells rolls the wells of a new moon of the given grade and type. The count comes from
// the grade's wellCountWeights and the resources from the type's affinities times the
// grade's (drawn without replacement). A player's first moon (first) always has every raw
// input of some recipe. Each well's supply is rolled on its own in the grade's
// [wellSupplyMin, wellSupplyMax).
// rollWells rolls the wells of a new moon of the given grade and type (rollWellsOnce). A
// 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 {
wells := rollWellsOnce(c, g, kind, first, rnd)
if g.MinValue <= 0 {
return wells
}
floor, best := g.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 {
wells, best = w, v
}
}
return wells
}
// rollWellsOnce rolls one moon's wells. The count comes from the grade's wellCountWeights
// and the resources from the type's affinities times the grade's (drawn without
// replacement). A player's first moon (first) always has every raw input of some recipe.
// Each well's supply is rolled on its own in the grade's [wellSupplyMin, wellSupplyMax).
func rollWellsOnce(c *GameConfig, g *MoonGrade, kind MoonType, first bool, rnd func() float64) []wellSupply {
names := sortedKeys(c.ResourcePrices)
weight := func(name string) float64 {
w := 1.0
@@ -1321,7 +1338,11 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
return res, err
}
// redrill puts the idle tools whose last stake was on a well back to work on it.
// redrill puts the idle tools whose last stake was on a well back to work on it, picked
// to fill what the well has left with the least to spare: while there is room, the
// smallest drill that would finish the job alone, else the biggest one and again (two
// 4-unit drills and a 20-unit one on a well with 3 left: one 4-unit drill goes). The rest
// stay idle.
func redrill(ctx context.Context, db *pgxpool.Pool, userID, wellID string) (int, error) {
if !uuidPattern.MatchString(wellID) {
return 0, gameErr("Well not found")
@@ -1329,7 +1350,8 @@ func redrill(ctx context.Context, db *pgxpool.Pool, userID, wellID string) (int,
var n int
err := inTx(ctx, db, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
select inventory_item.id from inventory_item
select inventory_item.id, store_item.claim_amount from inventory_item
join store_item on store_item.id = inventory_item.store_item_id
join lateral (
select staking_event.id, well_id from staking_event
where inventory_item_id = inventory_item.id
@@ -1342,17 +1364,41 @@ func redrill(ctx context.Context, db *pgxpool.Pool, userID, wellID string) (int,
if err != nil {
return err
}
tools, err := pgx.CollectRows(rows, pgx.RowTo[string])
type idle struct {
ID string
Amount float64
}
tools, err := pgx.CollectRows(rows, pgx.RowToStructByPos[idle])
if err != nil {
return err
}
if len(tools) == 0 {
return gameErr("No idle drills to redrill here")
}
var moonID string
if err := tx.QueryRow(ctx, "select source_id from resource_well where id = $1", wellID).Scan(&moonID); err != nil {
return err
}
// room is what the well has left after the drills already on it (and the ones
// just sent) take their share.
room := func() (float64, error) {
m, stakes, err := moonInTx(ctx, tx, userID, moonID)
if err != nil {
return 0, err
}
return newStakeTake(m, stakes, wellID, 1e18, 1), nil
}
n = 0
// Until the well has no room left: the rest stay idle (none fits: the error).
for _, id := range tools {
err := stakeInTx(ctx, tx, userID, id, wellID)
for len(tools) > 0 {
left, err := room()
if err != nil {
return err
}
if left <= 1e-9 {
// This well is full: drills that mine several wells may still have work on
// the moon (stakeInTx decides), in order, as before.
for _, t := range tools {
err := stakeInTx(ctx, tx, userID, t.ID, wellID)
if (err == errWellCommitted || err == errWellEmpty) && n > 0 {
break
} else if err != nil {
@@ -1360,11 +1406,48 @@ func redrill(ctx context.Context, db *pgxpool.Pool, userID, wellID string) (int,
}
n++
}
break
}
amounts := make([]float64, len(tools))
for i, t := range tools {
amounts[i] = t.Amount
}
pick := redrillPick(amounts, left)
err = stakeInTx(ctx, tx, userID, tools[pick].ID, wellID)
if (err == errWellCommitted || err == errWellEmpty) && n > 0 {
break
} else if err != nil {
return err
}
tools = slices.Delete(tools, pick, pick+1)
n++
}
return logEvent(ctx, tx, userID, "redrill", map[string]any{"well": wellID, "drills": n})
})
return n, err
}
// redrillPick is the drill (index into amounts, what each takes a claim) to send to a
// well with left to fill: the smallest that covers it alone, else the biggest.
func redrillPick(amounts []float64, left float64) int {
pick := -1
for i, a := range amounts {
if a >= left-1e-9 && (pick < 0 || a < amounts[pick]) {
pick = i
}
}
if pick >= 0 {
return pick
}
pick = 0
for i, a := range amounts {
if a > amounts[pick] {
pick = i
}
}
return pick
}
// coverWells picks the wells (indexes into supply) a stake with the given reach mines:
// the one it was deployed on (anchor; -1 for none) first, then the others richest first,
// leaving out empty ones, up to reach wells.
+3 -2
View File
@@ -13,8 +13,9 @@ package main
// - seen:<tab>, the revealed tab was opened (no more shine);
// - all: everything unlocked (players from before onboarding, admin "unlock all").
// Owning a moon is read from the game state. Redrill opens with the first claim. The store gate also reveals "+ New moon",
// Help, Leaderboard, moon sale, "Claim and redeploy", upgrades and the
// drill panel (attachments); Claim all also needs 2 tools.
// Help, Leaderboard, moon sale, "Claim and redeploy", upgrades, attachments and selling
// tools (the drill panel opens with Equipment and shows these parts once the Store is
// unlocked); Claim all also needs 2 tools.
import (
"context"
+24
View File
@@ -0,0 +1,24 @@
package main
import "testing"
// Redrill sends the smallest drill that finishes the well alone, else the biggest and again.
func TestRedrillPick(t *testing.T) {
cases := []struct {
amounts []float64
left float64
want int
}{
{[]float64{4, 4, 20}, 3, 0}, // a 4 covers 3: not the 20
{[]float64{20, 4, 4}, 4, 1}, // exactly 4
{[]float64{4, 20, 4}, 10, 1}, // only the 20 covers 10
{[]float64{4, 20, 4}, 30, 1}, // none covers 30: the biggest first
{[]float64{4, 4}, 6, 0}, // none covers 6: a 4, then the other
{[]float64{110, 20, 4}, 19, 1}, // the 20, not the 110
}
for _, c := range cases {
if got := redrillPick(c.amounts, c.left); got != c.want {
t.Errorf("redrillPick(%v, %v) = %d, want %d", c.amounts, c.left, got, c.want)
}
}
}
+3 -1
View File
@@ -32,7 +32,9 @@ func TestTemplates(t *testing.T) {
unlocked := newPlayerState() // everything open but no moon yet
unlocked.Onboarding = []string{"all"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
early := veteranState() // Equipment open, the Store not yet (a drill panel without its Store parts)
early.Onboarding = []string{"equipment", "market"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState(), early} {
for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
v = v.resolve(s)
+3 -1
View File
@@ -206,7 +206,9 @@ func (v View) resolve(s *State) View {
}
}
}
if v.Section != "equipment" || s.tool(v.Drill) == nil || !s.Unlocked("store") {
// A tool's panel opens with Equipment; what in it belongs to the Store (upgrade,
// attachments, selling) shows once the Store is unlocked (DrillPanel.Store).
if v.Section != "equipment" || s.tool(v.Drill) == nil || !s.Unlocked("equipment") {
v.Drill = ""
}
v.Drill = s.idleCopy(v.Drill)
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"bytes"
"strings"
"testing"
)
// A tool's panel opens as soon as Equipment is unlocked; its Store parts (upgrade,
// attachments, selling) show only once the Store is.
func TestViewDrillPanelOnboarding(t *testing.T) {
tmpl, err := (&server{}).parseTemplates()
if err != nil {
t.Fatalf("parse: %v", err)
}
for _, c := range []struct {
flags []string
open, store bool
}{
{nil, false, false},
{[]string{"equipment"}, true, false},
{[]string{"equipment", "market"}, true, false},
{[]string{"equipment", "market", "store"}, true, true},
{[]string{"all"}, true, true},
} {
s := veteranState()
s.Onboarding = c.flags
id := s.Tools[0].ID
v := View{Section: "equipment", Drill: id}.resolve(s)
if (v.Drill != "") != c.open {
t.Errorf("%v: drill %q, want open %v", c.flags, v.Drill, c.open)
continue
}
if !c.open {
continue
}
p := s.page(v)
if p.Drill == nil || p.Drill.Store != c.store {
t.Errorf("%v: panel %+v, want store %v", c.flags, p.Drill, c.store)
continue
}
var b bytes.Buffer
if err := tmpl.ExecuteTemplate(&b, "drill-panel", p.Drill); err != nil {
t.Fatalf("render: %v", err)
}
html := b.String()
if !strings.Contains(html, "<h3>Tiers</h3>") {
t.Errorf("%v: no tier ladder", c.flags)
}
for _, part := range []string{"/act/sell-tool", "<h3>Slots</h3>", "Spare attachments"} {
if strings.Contains(html, part) != c.store {
t.Errorf("%v: %q shown %v, want %v", c.flags, part, !c.store, c.store)
}
}
}
}
+8 -7
View File
@@ -364,13 +364,9 @@
{{$row := .}}
{{$d := $.V.ToDrill .ID}}
<article class="card tool-card {{if .Selected}}is-selected{{end}}">
{{if $open}}
<a class="card-media" href="{{$d}}" hx-get="{{$d}}" aria-label="{{.Item.Name}}: attachments"><img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'"></a>
{{else}}
<div class="card-media"><img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'"></div>
{{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>
<div class="card-body">
<h2>{{if $open}}<a href="{{$d}}" hx-get="{{$d}}" class="row-link">{{.Item.Name}}</a>{{else}}{{.Item.Name}}{{end}}{{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}} <span class="tier-chip num" title="Tier">T{{.Tier}}/{{.MaxTier}}</span></h2>
<p class="tool-status small">
{{with .Stake}}
{{$url := $.StakeURL .}}
@@ -810,8 +806,11 @@
{{define "drill-panel"}}
<h2>{{.Item.Name}} <span class="muted">T{{.Tier}}</span></h2>
<dl class="facts su-facts">{{template "tool-stats" .ToolRow}}</dl>
{{/* Before the Store is unlocked: stats, tiers and deploying only (DrillPanel.Store). */}}
{{if .Store}}
{{with .Preview}}{{if gt .RateGain 0.0}}<p class="su-income small num">Income {{curk .RateFrom}}/h ▸ {{curk .RateTo}}/h{{with .Payback}} · pays back in {{.}}{{end}}</p>{{end}}{{end}}
<div class="drill-upgrade">{{template "tool-upgrade" .ToolRow}}</div>
{{end}}
{{with .Ladder}}
<h3>Tiers</h3>
<ol class="tier-ladder num small">
@@ -823,7 +822,7 @@
{{end}}
</ol>
{{end}}
{{if .Mining}}<p class="muted small">Mining now. Changes apply from its next drill.</p>{{end}}
{{if .Mining}}<p class="muted small">Mining now.{{if .Store}} Changes apply from its next drill.{{end}}</p>{{end}}
{{with .IdleBest}}
{{$b := $.IdleURL}}
<div class="line">
@@ -836,6 +835,7 @@
</div>
{{end}}
{{if .Store}}
<h3>Slots</h3>
{{range .Slots}}
<div class="line">
@@ -871,6 +871,7 @@
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>
</div>
{{end}}
{{end}}
{{/* Claim button for a moon's multi-well drills (claim-wide), a DrillGroup. */}}
+10 -4
View File
@@ -79,11 +79,17 @@ func TestWellsDistribution(t *testing.T) {
mean := all / float64(total) / price
fmt.Printf("all moons: mean value %.0f (%.2f× the price), %.0f units, %.1f wells\n",
all/float64(total), mean, units/float64(total), float64(wellsRolled)/float64(total))
// A moon is worth about 1.3–1.8× its price at base prices (BALANCE-NOTES.md).
if mean < 1.3 || mean > 1.8 {
t.Errorf("grade %s: moons are worth %.2f× their price on average, want 1.3–1.8×", g.Grade, mean)
// A moon is worth about 1.3–1.8× its price at base prices (BALANCE-NOTES.md); a grade
// with a minValue (no duds early on) may average up to 2×.
hi := 1.8
if g.MinValue > 0 {
hi = 2.0
}
if perWell := units / float64(wellsRolled); math.Abs(perWell/(mid-0.5)-1) > 0.02 {
if mean < 1.3 || mean > hi {
t.Errorf("grade %s: moons are worth %.2f× their price on average, want 1.3–%.1f×", g.Grade, mean, hi)
}
// (a minValue re-rolls the poorest moons: their wells come out somewhat bigger)
if perWell := units / float64(wellsRolled); math.Abs(perWell/(mid-0.5)-1) > 0.02 && (g.MinValue == 0 || perWell < mid-0.5 || perWell > (mid-0.5)*1.1) {
t.Errorf("grade %s: mean well %.1f units, expected about %.1f", g.Grade, perWell, mid-0.5)
}
}