Compare commits

..
4 Commits
Author SHA1 Message Date
JosephFerano e267992615 Tool art: keep the tier ring and tops inside the deploy card crop 2026-10-19 22:37:26 +07:00
JosephFerano fa5fc46cb8 AGENTS.md: tool art script 2026-10-19 22:34:16 +07:00
JosephFerano 7af1c5a821 Show each tool's tier art in Equipment, deploy cards, drill lines and the API
StoreItem.ImageAt picks <image>-tN, falling back to the base image when a tier has
no file. API: state.tools and equipment rows use it; catalog tiers get an image.
TestAPI checks every tool has an image at every tier.
2026-10-19 22:33:44 +07:00
JosephFerano fcec292660 Tool art: generated drill images per tool and tier
scripts/tool-art.mjs draws each store item as an isometric SVG (own silhouette and
color per tool; tiers grow the rig, add parts, light a ring of tier segments and
strengthen the glow) and rasterizes it to web/static/assets/items. Tier 0 keeps the
existing file names; tier N adds -tN before the extension.
2026-10-19 22:33:44 +07:00
65 changed files with 728 additions and 1136 deletions

No files matched your search

+7 -8
View File
@@ -33,9 +33,7 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
owned for the moon price and well scale, and can't be sold. The Past tab loads them 10 at a
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
`loadView` (state, the past page, the resolved view).
owned for the moon price and well scale, and can't be sold.
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
@@ -98,10 +96,8 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
and unequip any time, they count from the next stake), store cards and the drill panel
(`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire and buy
seat rules, Advisors section. Tables `advisor_hire`, `users.advisor_seats`.
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
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
@@ -143,8 +139,11 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `scripts/tool-art.mjs`: draws the tool images (`web/static/assets/items`, one per tool and
tier: `<image>-tN`, `StoreItem.ImageAt`); rerun it after adding a tool or tier (needs
rsvg-convert and ImageMagick). A new tool needs a drawing there.
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–016):
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–015):
changes for existing ones, applied on every `migrate`, so each must be idempotent.
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
+26 -278
View File
@@ -1,15 +1,9 @@
package main
// Advisors: people a player hires once, for credits, who boost the whole operation (drill
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments).
// Seats are numbered: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Hiring needs a free seat and seats the advisor in the lowest one. Only
// seated advisors give their bonus. A hired advisor can be unseated (kept on the payroll,
// no bonus), seated again in a free seat, swapped with another (seated or not) or
// dismissed. Dismissing refunds nothing (the price is a hiring fee, like seats and slots,
// which aren't refunded either); hiring them again costs the full price. Like attachments,
// changes count from the next action: running drills keep their bonus.
// Tables advisor_hire (seat null: not seated) and users.advisor_seats.
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments). Each
// advisor takes a seat: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Tables advisor_hire and users.advisor_seats.
import (
"context"
@@ -70,199 +64,39 @@ func lockAdvisors(ctx context.Context, tx pgx.Tx, userID string) (int, error) {
return seats, err
}
// AdvisorHire is a hired advisor and their seat (0: not seated).
type AdvisorHire struct {
Name string
Seat int
}
// lockHires locks the player's row (see lockAdvisors) and reads their hires.
func lockHires(ctx context.Context, tx pgx.Tx, userID string) (int, []AdvisorHire, error) {
seats, err := lockAdvisors(ctx, tx, userID)
if err != nil {
return 0, nil, err
}
rows, err := tx.Query(ctx, `
select advisor, coalesce(seat, 0) from advisor_hire
where user_id = $1 order by hired_at, advisor for update`, userID)
if err != nil {
return 0, nil, err
}
hires, err := pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire])
return seats, hires, err
}
func findHire(hires []AdvisorHire, name string) *AdvisorHire {
for i := range hires {
if hires[i].Name == name {
return &hires[i]
}
}
return nil
}
// freeSeat is the lowest empty seat, 0 if every seat is taken.
func freeSeat(seats int, hires []AdvisorHire) int {
for n := 1; n <= seats; n++ {
taken := false
for _, h := range hires {
taken = taken || h.Seat == n
}
if !taken {
return n
}
}
return 0
}
// hiredOne finds a hired advisor by name, or the error for one who isn't.
func hiredOne(hires []AdvisorHire, name string) (*AdvisorHire, error) {
if h := findHire(hires, name); h != nil {
return h, nil
}
if cfg().advisor(name) == nil {
return nil, gameErr("Unknown advisor")
}
return nil, gameErr("%s doesn't work for you", name)
}
// setSeat moves a hired advisor to a seat (0: unseated).
func setSeat(ctx context.Context, tx pgx.Tx, userID, name string, seat int) error {
_, err := tx.Exec(ctx, "update advisor_hire set seat = nullif($3, 0) where user_id = $1 and advisor = $2",
userID, name, seat)
return err
}
// hireAdvisor hires an advisor into the lowest free seat.
// hireAdvisor hires an advisor into a free seat.
func hireAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
a := cfg().advisor(name)
if a == nil {
return gameErr("Unknown advisor")
}
return inTx(ctx, db, func(tx pgx.Tx) error {
seats, hires, err := lockHires(ctx, tx, userID)
seats, err := lockAdvisors(ctx, tx, userID)
if err != nil {
return err
}
if findHire(hires, a.Name) != nil {
var hired int
var already bool
err = tx.QueryRow(ctx, `
select count(*), coalesce(bool_or(advisor = $2), false) from advisor_hire where user_id = $1`,
userID, a.Name).Scan(&hired, &already)
if err != nil {
return err
}
if already {
return gameErr("%s already works for you", a.Name)
}
seat := freeSeat(seats, hires)
if seat == 0 {
if hired >= seats {
return gameErr("All advisor seats are taken. Buy another seat first")
}
if err := debit(ctx, tx, userID, a.Price); err != nil {
return err
}
_, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", userID, a.Name, seat)
_, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor) values ($1, $2)", userID, a.Name)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price, "seat": seat})
})
}
// seatAdvisor puts a hired advisor in an empty seat (moving them if they sit elsewhere).
func seatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string, seat int) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
seats, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if seat < 1 || seat > seats {
return gameErr("No such seat")
}
if h.Seat == seat {
return gameErr("%s already sits there", name)
}
for _, o := range hires {
if o.Seat == seat {
return gameErr("Seat %d is taken by %s", seat, o.Name)
}
}
if err := setSeat(ctx, tx, userID, name, seat); err != nil {
return err
}
return logEvent(ctx, tx, userID, "seat_advisor", map[string]any{"advisor": name, "seat": seat, "from": h.Seat})
})
}
// unseatAdvisor takes a seated advisor off their seat; they stay hired, without a bonus.
func unseatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if h.Seat == 0 {
return gameErr("%s isn't seated", name)
}
if err := setSeat(ctx, tx, userID, name, 0); err != nil {
return err
}
return logEvent(ctx, tx, userID, "unseat_advisor", map[string]any{"advisor": name, "seat": h.Seat})
})
}
// swapAdvisors exchanges two hired advisors' seats; one of them may be unseated (the
// other then gives up their seat to them).
func swapAdvisors(ctx context.Context, db *pgxpool.Pool, userID, name, other string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
a, err := hiredOne(hires, name)
if err != nil {
return err
}
b, err := hiredOne(hires, other)
if err != nil {
return err
}
if a.Name == b.Name {
return gameErr("Pick two different advisors")
}
if a.Seat == 0 && b.Seat == 0 {
return gameErr("Neither of them is seated")
}
// One at a time: the seats are unique.
for _, step := range []struct {
name string
seat int
}{{a.Name, 0}, {b.Name, a.Seat}, {a.Name, b.Seat}} {
if err := setSeat(ctx, tx, userID, step.name, step.seat); err != nil {
return err
}
}
return logEvent(ctx, tx, userID, "swap_advisors", map[string]any{"advisor": a.Name, "with": b.Name,
"seat": b.Seat, "withSeat": a.Seat})
})
}
// dismissAdvisor lets a hired advisor go, freeing their seat. Nothing is refunded.
func dismissAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, "delete from advisor_hire where user_id = $1 and advisor = $2", userID, name); err != nil {
return err
}
return logEvent(ctx, tx, userID, "dismiss_advisor", map[string]any{"advisor": name, "seat": h.Seat})
return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price})
})
}
@@ -296,120 +130,42 @@ func buyAdvisorSeat(ctx context.Context, db *pgxpool.Pool, userID string) (int,
type AdvisorsPage struct {
Cards []AdvisorCard
Seats int
Hired int // seats filled
Benched int // hired, not seated
SeatList []SeatSlot
Bench []AdvisorCard // hired, not seated
FreeSeat int // the lowest empty seat, 0 if none
Seats, Hired int
MaxSeats int
NextSeat int // number of the next seat to buy
NextSeatPrice float64 // 0 at the cap
CanBuySeat bool
Bonuses []BonusRow // the combined advisor bonus, capped
Panel *AdvisorPanel // the selected hired advisor (View.Adv)
}
// SeatSlot is a seat and who sits in it (nil: empty).
type SeatSlot struct {
N int
Advisor *AdvisorCard
}
type AdvisorCard struct {
*Advisor
Hired bool
Seat int // 0: not seated (or not hired)
CanAfford bool
}
// AdvisorPanel is a hired advisor's details, with what can change: their seat, a swap or
// dismissing them.
type AdvisorPanel struct {
AdvisorCard
Moves []SeatSlot // every other seat: move there (empty) or swap with who sits there
Swaps []AdvisorCard // seated: the unseated advisors who can take their seat
Dismiss string // the dismiss dialog's question
}
func (s *State) advisorsPage(v View) *AdvisorsPage {
func (s *State) advisorsPage() *AdvisorsPage {
c := cfg()
p := &AdvisorsPage{Seats: s.AdvisorSeats, MaxSeats: 1 + len(c.Advisors.SeatPrices),
p := &AdvisorsPage{Seats: s.AdvisorSeats, Hired: len(s.Advisors), MaxSeats: 1 + len(c.Advisors.SeatPrices),
NextSeat: s.AdvisorSeats + 1,
Bonuses: s.BonusRows()}
if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) {
p.NextSeatPrice = c.Advisors.SeatPrices[n]
p.CanBuySeat = s.Balance >= p.NextSeatPrice
}
card := func(a *Advisor) AdvisorCard {
card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price}
for _, h := range s.Hires {
if h.Name == a.Name {
card.Hired, card.Seat = true, h.Seat
}
}
return card
}
for i := range c.Advisors.Items {
p.Cards = append(p.Cards, card(&c.Advisors.Items[i]))
a := &c.Advisors.Items[i]
card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price}
for _, h := range s.Advisors {
card.Hired = card.Hired || h == a.Name
}
// Hired advisors, also those since removed from the config (no bonus).
hired := func(name string) AdvisorCard {
if a := c.advisor(name); a != nil {
return card(a)
}
return card(&Advisor{Name: name, Title: "Advisor"})
}
p.SeatList = make([]SeatSlot, s.AdvisorSeats)
for i := range p.SeatList {
p.SeatList[i].N = i + 1
}
for _, h := range s.Hires {
hc := hired(h.Name)
if h.Seat >= 1 && h.Seat <= len(p.SeatList) {
p.SeatList[h.Seat-1].Advisor = &hc
p.Hired++
} else {
p.Bench = append(p.Bench, hc)
p.Benched++
}
}
for _, slot := range p.SeatList {
if slot.Advisor == nil {
p.FreeSeat = slot.N
break
}
}
if v.Adv != "" && s.hire(v.Adv) != nil {
pc := hired(v.Adv)
panel := &AdvisorPanel{AdvisorCard: pc, Dismiss: dismissConfirm(pc)}
for _, slot := range p.SeatList {
if slot.N != pc.Seat {
panel.Moves = append(panel.Moves, slot)
}
}
if pc.Seat > 0 {
panel.Swaps = p.Bench
}
p.Panel = panel
p.Cards = append(p.Cards, card)
}
return p
}
// hire finds a hired advisor by name.
func (s *State) hire(name string) *AdvisorHire { return findHire(s.Hires, name) }
// dismissConfirm is the question before dismissing an advisor.
func dismissConfirm(c AdvisorCard) string {
if c.Price > 0 {
return fmt.Sprintf("Dismiss %s? The hiring fee isn't refunded: hiring them again costs %s.",
c.Name, formatCredits(c.Price))
}
return fmt.Sprintf("Dismiss %s?", c.Name)
}
// SeatFree reports whether the player has an empty advisor seat.
func (p *AdvisorsPage) SeatFree() bool { return p.FreeSeat > 0 }
func (p *AdvisorsPage) SeatFree() bool { return p.Hired < p.Seats }
// SeatConfirm is the question before buying the next seat.
func (p *AdvisorsPage) SeatConfirm() string {
@@ -428,11 +184,3 @@ func (p *AdvisorsPage) HireWhy(c AdvisorCard, balance float64) string {
}
return ""
}
// SeatWhy is why an unseated advisor can't take a seat now, "" if they can.
func (p *AdvisorsPage) SeatWhy() string {
if p.SeatFree() {
return ""
}
return "All seats taken. Unseat someone or buy another seat"
}
+10 -57
View File
@@ -40,8 +40,7 @@ type APIState struct {
Idle int `json:"idle"` // tools not mining
NextEnd *int64 `json:"nextEnd"`
Moons []APIMoonListItem `json:"moons"` // active moons
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first: one page (page=)
PastPage APIPastPage `json:"pastPage"`
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first
Tools []APITool `json:"tools"`
Stakes []APIStake `json:"stakes"` // every running or unclaimed drill
Moon *APIMoon `json:"moon"` // s=moons with a moon
@@ -76,17 +75,6 @@ type APIView struct {
Past bool `json:"past"`
Pick string `json:"pick"`
Drill string `json:"drill"`
Page int `json:"page"` // the page of past moons (from 1)
Adv string `json:"adv"` // the selected hired advisor (advisors section)
Aside bool `json:"aside"` // the web shows the aside (viewport and side panel)
}
// APIPastPage is the page of past moons in past: request others with page=<n>.
type APIPastPage struct {
Page int `json:"page"` // from 1, most recently finished first
Pages int `json:"pages"` // at least 1
Total int `json:"total"` // past moons in all
PerPage int `json:"perPage"`
}
type APIAmount struct {
@@ -155,8 +143,6 @@ type APIStake struct {
Secs int `json:"durationSecs"`
Ready bool `json:"ready"`
Take float64 `json:"take"` // what claiming it would get now
// The web's words for its reach: "Mines 3 wells", "Strips the whole moon", "" for one.
ReachText string `json:"reachText"`
Covers []string `json:"covers,omitempty"` // multi-well drills: the wells it mines
// Moon detail only (projection, claims oldest first): what it gets in all and from
// this well, and whether that is its full amount (ok, short, none).
@@ -200,14 +186,6 @@ type APIWellRow struct {
Remain float64 `json:"remain"`
DryAfter int `json:"dryAfter"` // the claim that empties it (0: none)
DryAt *int64 `json:"dryAt"`
// Shared supply, as the web's well note: over (drills want more than it holds), full
// (they'll drain it), statusText the note's lead ("Over-drilled — next claims will come
// up short", "Fully committed" or ""), noteText the rest ("47 left · 2 committed · 45
// free after", "" with no demand).
Over bool `json:"over"`
Full bool `json:"full"`
StatusText string `json:"statusText"`
NoteText string `json:"noteText"`
}
type APIWell struct {
@@ -273,18 +251,6 @@ type APIQuote struct {
Base float64 `json:"base"`
SellAll float64 `json:"sellAll"`
Spark []float64 `json:"spark,omitempty"` // s=market: 24h, every 3rd point (15 minutes) and now
// The web's texts: up (change >= 0, green ▲), changeText "+1.25%"/"−0.50%"; pressed
// (selling pressure lowers the price noticeably), pressureText "−4%"; boosted (a live
// event), boostText "×2"; advised (an advisor sell bonus), advisorText "+8%". Each
// text is "" when its flag is false.
Up bool `json:"up"`
ChangeText string `json:"changeText"`
Pressed bool `json:"pressed"`
PressureText string `json:"pressureText"`
Boosted bool `json:"boosted"`
BoostText string `json:"boostText"`
Advised bool `json:"advised"`
AdvisorText string `json:"advisorText"`
}
// APIBanner is a banner line: an announcement or a live event (id matches liveEvents[].id).
@@ -331,7 +297,7 @@ func apiGear(gear []*Attachment) []string {
func apiStake(st StakeRow, projected bool) APIStake {
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers, ReachText: st.ReachText()}
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers}
if projected {
gets, here := st.Gets, st.Here
a.Gets, a.Here, a.State, a.Fate = &gets, &here, st.State, st.Fate()
@@ -370,9 +336,7 @@ func apiMoonList(rows []MoonListRow) []APIMoonListItem {
func apiState(p *Page, extra ...*Page) *APIState {
s, v := p.S, p.V
a := &APIState{UserID: s.UserID, Now: s.Now.UnixMilli(),
View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill,
Page: v.Page, Adv: v.Adv, Aside: p.Scene.Select != nil},
PastPage: APIPastPage{Page: s.PastPage, Pages: s.pastPages(), Total: s.PastTotal, PerPage: pastPerPage},
View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill},
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd),
Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{},
@@ -401,7 +365,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
continue
}
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
Image: itemImages + item.Image, Tier: t.Tier, MaxTier: len(item.Upgrades),
Image: itemImages + item.ImageAt(t.Tier), Tier: t.Tier, MaxTier: len(item.Upgrades),
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
if st := s.StakeForTool(t.ID); st != nil {
@@ -423,8 +387,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
for _, w := range d.Wells {
row := APIWellRow{APIWellSupply: APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply},
APIDrillGroup: apiGroup(w.DrillGroup), Wide: []string{}, Selected: w.Selected, Redrill: w.Redrill,
Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt),
Over: w.Over(), Full: w.Full(), StatusText: w.StatusText(), NoteText: w.NoteText()}
Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt)}
for _, st := range w.Wide {
row.Wide = append(row.Wide, st.ID)
}
@@ -539,17 +502,7 @@ func apiMarket(trade *MarketPage, full bool) *APIMarket {
aq := APIQuote{Item: q.Resource, Refined: q.Refined, Balance: q.Balance,
Price: q.Price, Rate: q.Rate, Boost: q.Boost, Advisor: q.Advisor, Pressure: q.Pressure, Factor: q.Factor,
Open: q.Open, Change: q.Change, High: q.High, Low: q.Low, Value: q.Value,
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base(),
Up: q.Up(), ChangeText: q.ChangeText(), Pressed: q.Pressed(), Boosted: q.Boosted(), Advised: q.Advised()}
if aq.Pressed {
aq.PressureText = q.PressureText()
}
if aq.Boosted {
aq.BoostText = q.BoostText()
}
if aq.Advised {
aq.AdvisorText = q.AdvisorText()
}
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base()}
// A sale is priced at the pressure factor averaged over what it adds (sell in game.go).
aq.SellAll = q.Balance * aq.Unpressed * c.MarketPressure.Average(q.Pressure, q.Balance*aq.Base)
if full {
@@ -647,11 +600,11 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
// apiPage loads the player's state and builds the state for a view. get reads the
// request's include= and catalog= (see apiStateWith).
func (s *server) apiPage(ctx context.Context, userID string, v View, get func(string) string) (*APIState, error) {
state, v, err := loadView(ctx, s.db, userID, v)
state, err := loadState(ctx, s.db, userID)
if err != nil {
return nil, err
}
page, err := s.apiPageFor(ctx, state, v)
page, err := s.apiPageFor(ctx, state, v.resolve(state))
if err != nil {
return nil, err
}
@@ -783,13 +736,13 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
created, res.View, res.Message = true, View{}, "New game started"
}
state, v, err := loadView(ctx, s.db, userID, res.View)
state, err := loadState(ctx, s.db, userID)
if err != nil {
apiServerError(w, err)
return
}
toast := res.toast(state)
page, err := s.apiPageFor(ctx, state, v)
page, err := s.apiPageFor(ctx, state, res.View.resolve(state))
if err != nil {
apiServerError(w, err)
return
+29 -61
View File
@@ -27,9 +27,7 @@ Banned players get `403`.
Query params (all optional, same as the web URL): `s` (section: `moons`, `equipment`,
`market`, `store`, `refinery`, `advisors`, `leaderboard`, `help`, `new-moon`), `moon`,
`well`, `list=past` (show a mined-out moon), `page` (the page of `past`, from 1: 10 past
moons a page, most recently finished first; out-of-range pages are clamped), `pick` (market:
selected quote), `drill`, `adv` (advisors: the hired advisor whose panel the web shows).
`well`, `list=past` (show a mined-out moon), `pick` (market: selected quote), `drill`.
`include` (comma-separated sections, or `all`): section blocks to add besides the one for
`s` (see [Sections](#sections)); locked ones are skipped. Invalid or locked values fall back
like the web (`state.view` is the resolved view). Opening a revealed section with `s=`
@@ -72,12 +70,8 @@ The other sections' actions:
| `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
| `collect` | `job` (job id), or none for every finished batch | |
| `buy-slot` | – | refinery slot |
| `hire` | `advisor` (name) | needs a free seat; takes the lowest one (`freeSeat`) |
| `hire` | `advisor` (name) | needs a free seat |
| `buy-seat` | – | advisor seat |
| `seat-advisor` | `advisor`, `seat` (from 1) | a hired advisor into an empty seat (moves them if seated) |
| `unseat-advisor` | `advisor` | off their seat: stays hired, no bonus |
| `swap-advisors` | `advisor`, `with` (names) | exchange seats; one of them may be unseated |
| `dismiss-advisor` | `advisor` | frees their seat; nothing is refunded (confirm with `dismiss`) |
| `sell-moon` | `sell-moon` (moon id) | `moon.sale.canSell`; view moves to another moon |
| `new-game` | – | creates a new player and returns its id and state |
@@ -104,27 +98,26 @@ session; the web page also reads it once per page load.
| key | |
|---|---|
| `userId`, `now` | |
| `view` | `{section, moon, well, past, page, pick, drill, adv, aside}`, resolved (`page` clamped, `adv` only a hired advisor in `s=advisors`); `aside`: the web shows the aside (viewport and side panel): moon view, new-moon teaser, an open drill or advisor panel |
| `view` | `{section, moon, well, past, pick, drill}`, resolved |
| `credits` | balance |
| `resources`, `refined` | `[{name, amount}]` |
| `moonPrice` | price of the next moon |
| `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false |
| `shine` | revealed sections not opened yet |
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
| `pastPage` | `{page, pages, total, perPage}`: the page of past moons in `past` (`total` in all); ask for others with `page=` |
| `moons`, `past` | `past` is one page (`pastPage`). `[{id, name, kind, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18") |
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL; `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, reachText, start, end, durationSecs, ready, take, covers?}`; `reachText` the web's "Mines 3 wells" / "Strips the whole moon" (`""` for one well) |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `moons`, `past` | `[{id, name, kind, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18") |
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL, the tool's art at its tier (`shovel-t2.png` at T2; tier 0 and tiers without art use the base image); `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, start, end, durationSecs, ready, take, covers?}` |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `pressureText` "−4%" shown when `pressed` (recent selling lowers the price), `boostText` "×2" when `boosted` (live event), `advisorText` "+8%" when `advised` (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; `chart` and `spark` only for `s=market` (or `include=market`) |
| `banner` | `[{level, text, left, kind, id, end}]` announcements, then live events: `kind` `announcement` or `event`, `id` the announcement's or live event's id (an event's matches `liveEvents[].id`), `end` when it ends; `left` the web's "3h 20m left" at `now` (events only, else `""`) |
| `liveEvents` | active live events: `[{id, name, kind, resource, multiplier, start, end, headline}]`, `kind` `price_multiplier`, `cycle_multiplier` or `yield_multiplier` |
| `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
| `jobs` | once the refinery is open: every batch `[{id, slot, item, qty, start, end, ready}]` (the Refinery tab's badge) |
| `bonuses` | active advisor bonuses (the web's bonus chips): `[{kind, mult, capped, label, text}]`, `kind` `speed`, `yield`, `sell`, `refine`; `label` and `text` the chip ("Drill speed", "+10%") |
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, slotsPerTool, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkExponent, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the config max, or the biggest owned well if bigger); `boosts` are live event multipliers by resource or `"all"` |
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, reach, parts: [{name, amount}]}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, reach, parts: [{name, amount}], image}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`; a tool's `image` is its tier 0 art, each tier's `image` its art from that tier on); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
| `catalogVersion` | a hash of the catalog, for `catalog=` |
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
@@ -145,6 +138,7 @@ short code, listed with each field, and the web's text next to it.
- Copies of a tool (same type, tier and attachments) are one row: `count` copies, `idle`
of them idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill.
- `image`: the tool's art at its tier, like `state.tools`.
- `next`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}],
canAfford, blocked, short, blockedText, shortText}`; `blocked` is `credits` or `parts`
(`""` when it can be bought), `short` the credits missing; `blockedText` the button's
@@ -199,31 +193,14 @@ inputs"). `buySlot` is the buy-slot dialog (`null` at the cap), `buySlotWhyText`
### advisors (`s=advisors`)
```
{seats, hired, benched, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, freeSeat, seatList: [{n, advisor}],
bench, bonuses, buySeat, buySeatWhyText, selected,
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, seat,
canAfford, canHire, why, whyText, canSeat, seatWhy, seatWhyText, dismiss, removed}]}
{seats, hired, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, bonuses, buySeat, buySeatWhyText,
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, canAfford,
canHire, why, whyText}]}
```
Seats are numbered from 1. Only seated advisors give their bonus (`bonuses`); a hired
advisor can be unseated and stays hired. Changes count from the next stake, sale or batch.
`hired` is the seats filled, `benched` the advisors hired but not seated, `seatList` every
seat with the name of who sits there (`""` empty), `bench` the names of the hired advisors
not seated in the web's order (hire order), `freeSeat` the lowest empty seat (0 if
none: where `hire` seats a new one). `selected` is the web's open panel (`view.adv`).
`buySeat` is the buy-seat dialog (`null` at the cap), `buySeatWhyText` why "+ Seat" is
disabled. An advisor's `why` (hiring): `hired` ("On your board"), `seats` ("All seats
taken. Buy another seat first"), `credits` ("Need ₥X more") or `""`. For a hired advisor:
`seat` (0: not seated), `dismiss` the dismiss-advisor dialog (`null` if not hired; nothing
is refunded, hiring them again costs `price`); unseated ones also `canSeat` (a seat is
free: `seat-advisor` with `seat=<freeSeat>`) and `seatWhy` `seats` ("All seats taken.
Unseat someone or buy another seat") or `""`. A hired advisor no longer in the config comes
last with `removed` (no bonus, no card on the web; it shows in its seat or the bench and can be
dismissed).
The web's panel for a hired advisor (any client may do the same): for every other seat,
`seat-advisor` to an empty one or `swap-advisors` with who sits there; when seated, also
`swap-advisors` with each unseated advisor and `unseat-advisor`; and `dismiss-advisor`.
disabled. An advisor's `why`: `hired` ("On your board"), `seats` ("All seats taken. Buy
another seat first"), `credits` ("Need ₥X more") or `""`.
### leaderboard (`s=leaderboard`)
@@ -245,15 +222,12 @@ balance after buying, `need` the credits missing, `whyText` why the button is di
```
{moonId, name, kind, description, rating, headline, sub,
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}],
next: {price, canBuy, why, whyText}}
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}]}
```
`headline`, `sub` and `traits` are the card's verdict text; `worth` its wells at market
prices, `multiple` worth / its price; a well bar is `supply / maxSupply`, `full` from
`fullAt`. `next` is the card's "Buy another" button (`buy-moon` again): the next moon's
price, `canBuy`, and `why` `locked` (more moons come with the store: the web hides the
button), `credits` ("Need ₥X more", the button disabled) or `""`.
`fullAt`.
### buyModal (in the `buy-tool` response)
@@ -284,7 +258,7 @@ is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkl
## Texts
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `newGame`): `{title, text, ok, danger}`,
the web's title, question, OK button and whether it's destructive, e.g.
`{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 (T0) for ₥750? Its
attachments go back to your inventory.", "ok": "Sell for ₥750", "danger": true}`. Texts with
@@ -293,8 +267,11 @@ a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.no
Not sent (the client words them from the numbers, like the web's templates):
- moon list status: "N drills", "Idle", "Tapped out"; "A well here has more drills than
supply" (`over`).
- well note: "Over-drilled — next claims will come up short", "Fully committed", "X left · Y
committed · runs dry after N claims" / "Z free after" (`demand`, `remain`, `dryAfter`).
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
current drills" (`well.idle[].short`, `here`, `free`).
- market: change ("+1.25%"), pressure ("−12%"), boost ("×2"), advisor ("+10%").
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
The web's formats:
@@ -341,7 +318,7 @@ Content-Type: application/json
"state": {
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
"now": 1792391815265,
"view": {"section": "moons", "moon": "54daa782-…", "well": "b9d643c4-…", "past": false, "page": 1, "pick": "", "drill": "", "adv": "", "aside": true},
"view": {"section": "moons", "moon": "54daa782-…", "well": "b9d643c4-…", "past": false, "pick": "", "drill": ""},
"credits": 1644.63,
"resources": [{"name": "Azurium", "amount": 0}, {"name": "Nebulance", "amount": 0}],
"refined": [],
@@ -355,7 +332,6 @@ Content-Type: application/json
"drills": 1, "ready": 0, "over": false, "selected": true, "createdAt": 1792391738240,
"finished": false, "finishedAt": null}],
"past": [],
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5,
"reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}],
@@ -378,7 +354,7 @@ Content-Type: application/json
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"},
"catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
"catalogVersion": "9adb4eabab929f5d"
"catalogVersion": "7d983412e77a3dde"
}
}
```
@@ -391,7 +367,7 @@ From a live server (long ids and arrays shortened).
```json
{"tools": [{"id": "7e40…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
"image": "/static/assets/items/shovel-t1.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true,
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0},
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1,
@@ -433,19 +409,12 @@ From a live server (long ids and arrays shortened).
`s=advisors`, `state.advisors`:
```json
{"seats": 2, "hired": 1, "benched": 1, "maxSeats": 4, "nextSeat": 3, "nextSeatPrice": 40000, "canBuySeat": true,
"seatFree": true, "freeSeat": 2, "seatList": [{"n": 1, "advisor": "Ada Voss"}, {"n": 2, "advisor": ""}], "bench": ["Mira Castellanos"],
"bonuses": [{"kind": "speed", "mult": 1.1, "capped": false, "label": "Drill speed", "text": "+10%"}], "selected": "",
"buySeat": {"title": "New seat", "text": "Add advisor seat 3 for ₥40,000?", "ok": "Buy seat", "danger": false},
"buySeatWhyText": "",
{"seats": 1, "hired": 0, "maxSeats": 4, "nextSeat": 2, "nextSeatPrice": 10000, "canBuySeat": true, "seatFree": true,
"bonuses": [],
"advisors": [{"name": "Ada Voss", "title": "Shift Foreman", "image": "/static/assets/advisors/ada-voss.png",
"description": "Runs the drill crews in tight shifts. No rig sits idle on her watch.", "price": 8000,
"effect": {"speed": 1.1, "yield": 1, "sell": 1, "refine": 1}, "effectText": "+10% drill speed",
"hired": true, "seat": 1, "canAfford": true, "canHire": false, "why": "hired", "whyText": "On your board",
"canSeat": false, "seatWhy": "", "seatWhyText": "",
"dismiss": {"title": "Dismiss advisor", "text": "Dismiss Ada Voss? The hiring fee isn't refunded: hiring them again costs ₥8,000.",
"ok": "Dismiss", "danger": true}, "removed": false},
{"name": "Mira Castellanos", "…": "…", "hired": true, "seat": 0, "canSeat": true, "seatWhy": ""}, "…"]}
"effect": {"speed": 1.1, "yield": 1, "sell": 1, "refine": 1}, "hired": false, "canAfford": true,
"canHire": true}, "…"]}
```
`s=leaderboard`: `{"players": 2, "top": [{"rank": 1, "name": "…", "credits": 73579.5, "me": true}, "…"], "you": null, "me": {"rank": 1, "credits": 73579.5}}`
@@ -464,8 +433,7 @@ From a live server (long ids and arrays shortened).
"rating": {"tier": "poor", "label": "Poor", "stars": 1, "pct": 12, "top": 88},
"headline": "Slim pickings", "sub": "Not much down there. Better luck next time.", "worth": 717, "multiple": 1.79,
"traits": [], "maxSupply": 90, "fullAt": 84,
"wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"],
"next": {"price": 550, "canBuy": true, "why": "", "whyText": ""}}
"wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"]}
```
`POST /api/act/buy-tool`, `buyModal`:
+14 -86
View File
@@ -63,6 +63,7 @@ type APITierUp struct {
ClaimBoost float64 `json:"claimBoost"`
Reach int `json:"reach"` // wells per cycle from this tier on
Parts []APIAmount `json:"parts"` // refined items it uses up
Image string `json:"image"` // the tool's image at this tier
}
type APIEffect struct {
@@ -147,7 +148,8 @@ func apiCatalog(s *State) *APICatalog {
Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}}
for _, u := range item.Upgrades {
t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost,
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier))})
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier)),
Image: itemImages + item.ImageAt(u.Tier)})
}
cat.Tools = append(cat.Tools, t)
}
@@ -452,7 +454,7 @@ func apiEquipment(p *Page) *APIEquipment {
for _, row := range p.Equipment {
b := s.toolBonus(row.ID)
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
Image: itemImages + row.Item.Image, Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle,
Image: itemImages + row.Item.ImageAt(row.Tier), Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle,
Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield, Attachments: apiGear(row.Gear),
Slots: len(row.Gear) + row.FreeSlots, FreeSlots: row.FreeSlots, Selected: row.Selected,
Ladder: apiTierRows(row.Ladder), Resale: row.Item.ToolResale(row.Tier), NoSale: s.noSale(row),
@@ -726,27 +728,16 @@ func apiRefinery(p *Page) *APIRefinery {
type APIAdvisors struct {
Seats int `json:"seats"`
Hired int `json:"hired"` // seats filled
Benched int `json:"benched"` // hired, not seated
Hired int `json:"hired"`
MaxSeats int `json:"maxSeats"`
NextSeat int `json:"nextSeat"`
NextSeatPrice float64 `json:"nextSeatPrice"` // 0 at the cap
CanBuySeat bool `json:"canBuySeat"`
SeatFree bool `json:"seatFree"`
FreeSeat int `json:"freeSeat"` // the lowest empty seat, 0 if none
SeatList []APISeat `json:"seatList"` // every seat, from 1
Bench []string `json:"bench"` // hired, not seated, in the web's order (hire order)
Bonuses []APIBonusRow `json:"bonuses"`
Advisors []APIAdvisorUp `json:"advisors"`
BuySeat *APIConfirm `json:"buySeat"` // before buy-seat; null at the cap
BuySeatWhy string `json:"buySeatWhyText"` // why "+ Seat" is disabled, "" if it isn't
Selected string `json:"selected"` // the advisor of the web's panel (view.adv), "" if none
}
// APISeat is a seat and who sits in it ("" empty).
type APISeat struct {
N int `json:"n"`
Advisor string `json:"advisor"`
}
type APIAdvisorUp struct {
@@ -757,48 +748,26 @@ type APIAdvisorUp struct {
Price float64 `json:"price"`
Effect APIEffect `json:"effect"`
Hired bool `json:"hired"`
Seat int `json:"seat"` // hired: their seat, 0 if not seated (no bonus)
CanAfford bool `json:"canAfford"`
CanHire bool `json:"canHire"` // not hired, a seat free, affordable
EffectText string `json:"effectText"`
Why string `json:"why"` // hired, seats (none free), credits; "" can hire
WhyText string `json:"whyText"`
// Hired and not seated: whether seat-advisor can seat them now (in freeSeat);
// seatWhy "seats" (none free) or "".
CanSeat bool `json:"canSeat"`
SeatWhy string `json:"seatWhy"`
SeatWhyText string `json:"seatWhyText"`
Dismiss *APIConfirm `json:"dismiss"` // hired: the dismiss-advisor dialog; null otherwise
Removed bool `json:"removed"` // hired but no longer in the config: no card on the web
}
func apiAdvisors(p *Page) *APIAdvisors {
ap := p.Advisors
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, Benched: ap.Benched, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(), FreeSeat: ap.FreeSeat,
SeatList: []APISeat{}, Bench: []string{}, Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}, Selected: p.V.Adv}
for _, slot := range ap.SeatList {
seat := APISeat{N: slot.N}
if slot.Advisor != nil {
seat.Advisor = slot.Advisor.Name
}
a.SeatList = append(a.SeatList, seat)
}
for _, c := range ap.Bench {
a.Bench = append(a.Bench, c.Name)
}
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(),
Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}}
if ap.NextSeatPrice > 0 {
a.BuySeat = &APIConfirm{Title: "New seat", Text: ap.SeatConfirm(), OK: "Buy seat"}
a.BuySeatWhy = whyShort(ap.CanBuySeat, p.S.Balance, ap.NextSeatPrice)
}
up := func(c AdvisorCard) APIAdvisorUp {
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Description: c.Description, Price: c.Price,
Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)),
Hired: c.Hired, Seat: c.Seat, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(),
EffectText: c.Advisor.Effect()}
if c.Image != "" {
up.Image = advisorImages + c.Image
}
for _, c := range ap.Cards {
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Image: advisorImages + c.Image,
Description: c.Description, Price: c.Price, Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)),
Hired: c.Hired, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(), EffectText: c.Advisor.Effect()}
switch {
case c.Hired:
up.Why, up.WhyText = "hired", "On your board"
@@ -807,42 +776,11 @@ func apiAdvisors(p *Page) *APIAdvisors {
case !c.CanAfford:
up.Why, up.WhyText = "credits", ap.HireWhy(c, p.S.Balance)
}
if c.Hired {
up.Dismiss = &APIConfirm{Title: "Dismiss advisor", Text: dismissConfirm(c), OK: "Dismiss", Danger: true}
if c.Seat == 0 {
up.CanSeat = ap.SeatFree()
if !up.CanSeat {
up.SeatWhy, up.SeatWhyText = "seats", ap.SeatWhy()
}
}
}
return up
}
for _, c := range ap.Cards {
a.Advisors = append(a.Advisors, up(c))
}
// Hired advisors no longer in the config (no bonus) come last.
for _, c := range append(seated(ap.SeatList), ap.Bench...) {
if cfg().advisor(c.Name) == nil {
r := up(c)
r.Removed = true
a.Advisors = append(a.Advisors, r)
}
a.Advisors = append(a.Advisors, up)
}
return a
}
// seated lists the advisors in the seats.
func seated(seats []SeatSlot) []AdvisorCard {
var out []AdvisorCard
for _, slot := range seats {
if slot.Advisor != nil {
out = append(out, *slot.Advisor)
}
}
return out
}
// ------------------------------------------------------------------ leaderboard, help, new moon
type APILeaderboard struct {
@@ -942,15 +880,6 @@ type APIReveal struct {
MaxSupply float64 `json:"maxSupply"` // a full bar
FullAt float64 `json:"fullAt"` // wells from here count as full
Wells []APIRevealWell `json:"wells"`
Next APINextMoon `json:"next"` // "Buy another": buy-moon again
}
// APINextMoon is the reveal card's "Buy another" button.
type APINextMoon struct {
Price float64 `json:"price"` // the next moon's price
CanBuy bool `json:"canBuy"` // affordable and available
Why string `json:"why"` // locked (hide the button), credits; "" can buy
WhyText string `json:"whyText"`
}
type APIRevealWell struct {
@@ -967,8 +896,7 @@ func apiReveal(r *MoonReveal) *APIReveal {
a := &APIReveal{MoonID: r.ID, Name: r.Name, Kind: r.Kind, Description: r.Description,
Rating: *apiRating(&r.Rating),
Headline: r.Headline, Sub: r.Sub, Worth: r.Worth, Multiple: r.Multiple, Traits: []string{}, MaxSupply: r.MaxSupply,
FullAt: r.FullAt, Wells: []APIRevealWell{},
Next: APINextMoon{Price: r.Next.Price, CanBuy: r.Next.CanBuy, Why: r.Next.Why, WhyText: r.Next.WhyText}}
FullAt: r.FullAt, Wells: []APIRevealWell{}}
a.Traits = append(a.Traits, r.Traits...)
for _, w := range r.Wells {
a.Wells = append(a.Wells, APIRevealWell{ID: w.ID, Resource: w.Resource, Supply: w.Supply, Full: w.Supply >= r.FullAt})
+14 -41
View File
@@ -5,16 +5,24 @@ package main
import (
"encoding/json"
"io/fs"
"testing"
)
func TestAPI(t *testing.T) {
// Every tool has art at every tier (scripts/tool-art.mjs), else its URLs fall back.
for _, item := range game.StoreItems {
for tier := 0; tier <= len(item.Upgrades); tier++ {
name := item.ImageAt(tier)
if _, err := fs.Stat(embedded, "web/static/assets/items/"+name); err != nil || (tier > 0 && name == item.Image) {
t.Errorf("%s: no image for tier %d (run node scripts/tool-art.mjs)", item.Name, tier)
}
}
}
unlocked := newPlayerState()
unlocked.Onboarding = []string{"all"}
asides := map[bool]bool{}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} {
for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
p := s.page(v.resolve(s))
testRanking(p)
a := apiState(p)
@@ -54,8 +62,7 @@ func TestAPI(t *testing.T) {
}
if p.Moon != nil {
// The reveal card and the buy modal, as an action response maps them.
if r := apiReveal(p.moonReveal()); r == nil || len(r.Wells) != len(p.Moon.Wells) || r.Headline == "" ||
r.Next.Price != s.MoonPrice() || r.Next.CanBuy != (r.Next.Why == "") || (r.Next.Why == "") != (r.Next.WhyText == "") {
if r := apiReveal(p.moonReveal()); r == nil || len(r.Wells) != len(p.Moon.Wells) || r.Headline == "" {
t.Errorf("view %+v: reveal %+v", v, r)
}
}
@@ -83,13 +90,6 @@ func TestAPI(t *testing.T) {
if _, err := json.Marshal(all); err != nil {
t.Fatalf("marshal %+v with every section: %v", v, err)
}
// The aside: the moon view, the new-moon teaser, an open drill or advisor panel.
aside := p.Moon != nil || p.V.Section == "new-moon" || p.Drill != nil ||
p.Advisors != nil && p.Advisors.Panel != nil
if a.View.Aside != aside || aside != (p.Scene.Select != nil) {
t.Errorf("view %+v: aside %v, want %v", p.V, a.View.Aside, aside)
}
asides[a.View.Aside] = true
if a.View.Section != p.V.Section || a.View.Moon != p.V.Moon || a.View.Well != p.V.Well {
t.Errorf("view %+v: state has %+v", p.V, a.View)
}
@@ -104,18 +104,11 @@ func TestAPI(t *testing.T) {
if (a.Moon != nil) != (p.Moon != nil) || (a.Well != nil) != (p.Well != nil) {
t.Errorf("view %+v: moon or well missing", v)
}
if pp := a.PastPage; len(a.Past) > pp.PerPage || pp.Page < 1 || pp.Page > pp.Pages || pp.Total != s.PastTotal ||
a.View.Page != pp.Page || pp.Page < pp.Pages && len(a.Past) != pp.PerPage {
t.Errorf("view %+v: past page %+v with %d moons", v, pp, len(a.Past))
}
if (a.Market != nil) != s.Unlocked("market") {
t.Errorf("view %+v: market is %v, unlocked %v", v, a.Market != nil, s.Unlocked("market"))
}
}
}
if !asides[true] || !asides[false] {
t.Errorf("views with and without the aside: %v", asides)
}
a := apiState(newPlayerState().page(View{}.resolve(newPlayerState())))
if a.Unlocked["market"] || !a.Unlocked["new-moon"] || a.View.Section != "new-moon" {
t.Errorf("new player: unlocked %v, section %q", a.Unlocked, a.View.Section)
@@ -157,11 +150,8 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
if len(w.Drills) > 0 && w.Label == "" {
fail("well label", w)
}
if (w.Over || w.Full) != (w.StatusText != "") || (w.Demand > 0) != (w.NoteText != "") {
fail("well note", w)
}
for _, d := range w.Drills {
if d.Fate == "" || (d.Reach > 1) != (d.ReachText != "") {
if d.Fate == "" {
fail("drill fate", d)
}
}
@@ -170,13 +160,6 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
fail("moon sale", sale)
}
}
if a.Market != nil {
for _, q := range a.Market.Quotes {
if q.ChangeText == "" || q.Pressed != (q.PressureText != "") || q.Boosted != (q.BoostText != "") || q.Advised != (q.AdvisorText != "") {
fail("quote texts", q)
}
}
}
for _, b := range a.Bonuses {
if b.Label == "" || b.Text == "" {
fail("bonus", b)
@@ -234,10 +217,6 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
if (up.Why != "") != (up.WhyText != "") || up.Why == "" && !up.CanHire {
fail("advisor texts", up)
}
if up.Hired != (up.Dismiss != nil) || up.Dismiss != nil && up.Dismiss.Text == "" ||
(up.SeatWhy != "") != (up.SeatWhyText != "") || up.CanSeat && (up.Seat != 0 || ad.FreeSeat == 0) {
fail("advisor seat texts", up)
}
}
}
if h := a.Help; h != nil && h.NewGame.Text != newGameConfirm {
@@ -275,15 +254,9 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
t.Errorf("view %+v: refinery %+v", v, a.Refinery)
}
case "advisors":
if a.Advisors == nil || len(a.Advisors.Advisors) < len(cfg().Advisors.Items) || len(a.Advisors.SeatList) != s.AdvisorSeats ||
a.Advisors.Hired+a.Advisors.Benched != len(s.Hires) || a.Advisors.Selected != v.Adv || len(a.Advisors.Bench) != a.Advisors.Benched {
if a.Advisors == nil || len(a.Advisors.Advisors) != len(cfg().Advisors.Items) {
t.Errorf("view %+v: advisors %+v", v, a.Advisors)
}
for _, h := range s.Hires {
if h.Seat > 0 && a.Advisors.SeatList[h.Seat-1].Advisor != h.Name {
t.Errorf("view %+v: seat %d has %q, not %s", v, h.Seat, a.Advisors.SeatList[h.Seat-1].Advisor, h.Name)
}
}
case "leaderboard":
if a.Leaderboard == nil || a.Leaderboard.Me == nil || a.Leaderboard.Me.Rank != 12 {
t.Errorf("view %+v: leaderboard %+v", v, a.Leaderboard)
+5 -13
View File
@@ -114,12 +114,12 @@ func (cfg *GameConfig) validateBonuses() error {
// ------------------------------------------------------------------ in a transaction
// loadBonus reads the player's seated advisors and, with a tool id, the tool's attachments, and
// loadBonus reads the player's advisors and, with a tool id, the tool's attachments, and
// combines them. Callers that pass a tool have locked its row; the advisor rows are
// share-locked so a hire can't change them halfway through the action.
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
rows, err := tx.Query(ctx, `
select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
select advisor from advisor_hire where user_id = $1 order by advisor for share`, userID)
if err != nil {
return noBonus, err
}
@@ -143,28 +143,20 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, er
// ------------------------------------------------------------------ state
// loadBonusState loads the player's attachments, advisors (hired, and the seated ones) and
// advisor seats into the state.
// loadBonusState loads the player's attachments, advisors and advisor seats into the state.
func loadBonusState(ctx context.Context, db querier, s *State) error {
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
if err != nil {
return err
}
rows, err := db.Query(ctx, `
select advisor, coalesce(seat, 0) from advisor_hire where user_id = $1
order by seat nulls last, hired_at, advisor`, s.UserID)
select advisor from advisor_hire where user_id = $1 order by hired_at, advisor`, s.UserID)
if err != nil {
return err
}
if s.Hires, err = pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire]); err != nil {
if s.Advisors, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
return err
}
s.Advisors = nil
for _, h := range s.Hires {
if h.Seat > 0 {
s.Advisors = append(s.Advisors, h.Name)
}
}
rows, err = db.Query(ctx, `
select coalesce(inventory_item_id::text, ''), item from attachment
where user_id = $1 order by item`, s.UserID)
+29 -96
View File
@@ -10,10 +10,13 @@ import (
"encoding/hex"
"errors"
"fmt"
"io/fs"
"maps"
"math"
mrand "math/rand/v2"
"path"
"slices"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -113,12 +116,6 @@ type State struct {
Balance float64
Resources []ResourceBalance
Moons []Moon
// Past moons (finished): how many, the page shown (View.Page, from 1) and its moons,
// most recently finished first. Only those (and a selected past moon) have FinishedAt
// and Mined.
PastTotal int
PastPage int
PastIDs []string
Tools []Tool
Stakes []Stake
Now time.Time
@@ -136,12 +133,10 @@ type State struct {
Pressure map[string]float64 // the player's selling pressure by item, now (market.go)
// Bonuses (bonus.go): attachments equipped on each tool and spare ones (by kind),
// seated advisors (their bonus counts), every advisor hired with their seat, advisor
// seats owned.
// advisors hired, advisor seats owned.
Fitted map[string][]string
Spare map[string]int
Advisors []string
Hires []AdvisorHire // seated first, by seat
AdvisorSeats int
// Onboarding flags (onboarding.go): which parts of the game are revealed.
@@ -191,6 +186,20 @@ func (item *StoreItem) ToolReach(tier int) int {
return max(1, reach)
}
// ImageAt is the tool's image at a tier: tier N is the image's name with "-tN" before the
// extension (drawn by scripts/tool-art.mjs), else the tier 0 image when that file is missing.
func (item *StoreItem) ImageAt(tier int) string {
if tier <= 0 {
return item.Image
}
ext := path.Ext(item.Image)
name := fmt.Sprintf("%s-t%d%s", strings.TrimSuffix(item.Image, ext), tier, ext)
if _, err := fs.Stat(embedded, "web/static/assets/items/"+name); err != nil {
return item.Image
}
return name
}
func (item *StoreItem) NextUpgrade(tier int) *Upgrade {
for i := range item.Upgrades {
if item.Upgrades[i].Tier == tier+1 {
@@ -418,7 +427,9 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
for _, st := range s.Stakes {
delete(s.LastWell, st.ToolID)
}
loadPastMoons(s)
if err := loadPastMoons(ctx, db, s); err != nil {
return nil, err
}
if err := loadRefinery(ctx, db, s); err != nil {
return nil, err
}
@@ -440,31 +451,14 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
return s, nil
}
// loadView loads the player's state for a view (with the page of past moons it shows) and
// resolves the view.
func loadView(ctx context.Context, db *pgxpool.Pool, userID string, v View) (*State, View, error) {
s, err := loadState(ctx, db, userID)
if err != nil {
return nil, v, err
}
if err := loadPastPage(ctx, db, s, v.Page, v.Moon); err != nil {
return nil, v, err
}
return s, v.resolve(s), nil
}
// pastPerPage is how many past moons the moon list shows at a time (View.Page).
const pastPerPage = 10
// loadPastMoons marks the finished moons and counts them. Supply only goes down and every
// stake gets claimed, so a finished moon stays finished. What was mined on them is loaded
// a page at a time (loadPastPage).
func loadPastMoons(s *State) {
// loadPastMoons marks the finished moons and adds what was mined on each. Supply only goes
// down and every stake gets claimed, so a finished moon stays finished.
func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
busy := map[string]bool{}
for _, st := range s.Stakes {
busy[st.MoonID] = true
}
s.PastTotal = 0
past := map[string]*Moon{}
for i := range s.Moons {
m := &s.Moons[i]
m.Finished = len(m.Wells) > 0 && !busy[m.ID]
@@ -474,51 +468,13 @@ func loadPastMoons(s *State) {
}
}
if m.Finished {
s.PastTotal++
past[m.ID] = m
}
}
}
// pastPages is how many pages of past moons there are (at least 1).
func (s *State) pastPages() int { return max(1, (s.PastTotal+pastPerPage-1)/pastPerPage) }
// loadPastPage loads one page of past moons, most recently finished first (page 1), and
// what was mined on each; also on the selected moon if it's a past one on another page.
// The page is clamped to the ones there are.
func loadPastPage(ctx context.Context, db *pgxpool.Pool, s *State, page int, selected string) error {
s.PastPage = min(max(page, 1), s.pastPages())
s.PastIDs = []string{}
past := map[string]*Moon{}
var ids []string
for i := range s.Moons {
if s.Moons[i].Finished {
past[s.Moons[i].ID] = &s.Moons[i]
ids = append(ids, s.Moons[i].ID)
}
}
if len(ids) == 0 {
if len(past) == 0 {
return nil
}
rows, err := db.Query(ctx, `
select ids.id
from unnest($1::text[]) ids(id)
left join resource_well on resource_well.source_id::text = ids.id
left join staking_event on staking_event.well_id = resource_well.id
left join claim_event on claim_event.staking_event_id = staking_event.id
group by ids.id
order by max(claim_event.created_at) desc nulls last, ids.id
limit $2 offset $3`, ids, pastPerPage, (s.PastPage-1)*pastPerPage)
if err != nil {
return err
}
if s.PastIDs, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
return err
}
load := slices.Clone(s.PastIDs)
if past[selected] != nil && !slices.Contains(load, selected) {
load = append(load, selected)
}
rows, err = db.Query(ctx, `
select resource_well.source_id, resource.name,
sum(coalesce(claim_well.amount, claim_event.claim_amount)), max(claim_event.created_at)
from claim_event
@@ -526,9 +482,9 @@ func loadPastPage(ctx context.Context, db *pgxpool.Pool, s *State, page int, sel
left join claim_well on claim_well.claim_event_id = claim_event.id
join resource_well on resource_well.id = coalesce(claim_well.well_id, staking_event.well_id)
join resource on resource.id = resource_well.resource_id
where staking_event.user_id = $1 and resource_well.source_id::text = any($2)
where staking_event.user_id = $1
group by resource_well.source_id, resource.name
order by resource.name`, s.UserID, load)
order by resource.name`, s.UserID)
if err != nil {
return err
}
@@ -547,29 +503,6 @@ func loadPastPage(ctx context.Context, db *pgxpool.Pool, s *State, page int, sel
return err
}
// pagePastInMemory picks a page of past moons by FinishedAt, as loadPastPage does in SQL
// (for states built without a database).
func (s *State) pagePastInMemory(page int) {
s.PastTotal = 0
for _, m := range s.Moons {
if m.Finished {
s.PastTotal++
}
}
s.PastPage = min(max(page, 1), s.pastPages())
var past []*Moon
for i := range s.Moons {
if s.Moons[i].Finished {
past = append(past, &s.Moons[i])
}
}
slices.SortStableFunc(past, func(a, b *Moon) int { return b.FinishedAt.Compare(a.FinishedAt) })
s.PastIDs = []string{}
for _, m := range past[min(len(past), (s.PastPage-1)*pastPerPage):min(len(past), s.PastPage*pastPerPage)] {
s.PastIDs = append(s.PastIDs, m.ID)
}
}
// moonFinished reports whether a moon has just been mined out: its wells are empty and
// nothing on it is left to claim.
func moonFinished(ctx context.Context, tx pgx.Tx, moonID string) (bool, error) {
+1 -2
View File
@@ -141,8 +141,7 @@ var actionGates = map[string]string{
"equip": "store", "unequip": "store", "redrill": "market", "new-game": "help",
"sell": "market",
"refine": "refinery", "collect": "refinery", "buy-slot": "refinery",
"hire": "advisors", "buy-seat": "advisors", "seat-advisor": "advisors", "unseat-advisor": "advisors",
"swap-advisors": "advisors", "dismiss-advisor": "advisors",
"hire": "advisors", "buy-seat": "advisors",
"buy-moon": "new-moon",
}
+1 -19
View File
@@ -52,17 +52,6 @@ type MoonReveal struct {
Traits []string // notable traits and their rarity
MaxSupply float64 // the biggest well a moon can have: a full bar
FullAt float64 // wells from here count as full
Next NextMoon // buying another one right away
}
// NextMoon is the card's "Buy another": the next moon's price and whether the player can
// buy it now. Why: "locked" (more moons come with the store, onboarding.go), "credits" or
// "".
type NextMoon struct {
Price float64
CanBuy bool
Why string
WhyText string
}
var countWords = []string{"", "one", "two", "three", "four", "five", "six", "seven", "eight"}
@@ -79,14 +68,7 @@ func (p *Page) moonReveal() *MoonReveal {
for i, w := range p.Moon.Wells {
wells[i] = wellSupply{Resource: w.Resource, Supply: w.Supply}
}
r := &MoonReveal{MoonDetail: p.Moon, Next: NextMoon{Price: p.S.MoonPrice()}}
switch {
case !p.S.Unlocked("store"):
r.Next.Why, r.Next.WhyText = "locked", "Not available yet"
case p.S.Balance < r.Next.Price:
r.Next.Why, r.Next.WhyText = "credits", shortBy(r.Next.Price-p.S.Balance)
}
r.Next.CanBuy = r.Next.Why == ""
r := &MoonReveal{MoonDetail: p.Moon}
if p.Moon.Rating != nil {
r.Rating = *p.Moon.Rating
} else {
+545
View File
@@ -0,0 +1,545 @@
// Tool art: draws every store item (drill) at every tier as an SVG and rasterizes it into
// web/static/assets/items (rsvg-convert + ImageMagick). Tier 0 keeps the item's `image` name
// from config/game.json; tier N is the same name with "-tN" before the extension (toolImage
// in game.go). Run from the repo root: node scripts/tool-art.mjs [--svg dir]
//
// Each tool has its own silhouette and color family; tiers grow the rig, add parts, light
// up a ring of tier segments around its base and strengthen the glow.
import { readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
const SIZE = 512;
const OUT = "web/static/assets/items";
// ------------------------------------------------------------------ helpers
const C = Math.cos(Math.PI / 6);
const R2 = Math.SQRT2;
const f = (n) => +n.toFixed(1);
// Isometric projection: X runs down-right, Y down-left, Z up.
// While BB is set, every projected point grows it (the rig's bounding box, for fitting).
let BB = null;
function grow(x, y, r = 0) {
if (!BB) return;
BB.x0 = Math.min(BB.x0, x - r); BB.x1 = Math.max(BB.x1, x + r);
BB.y0 = Math.min(BB.y0, y - r); BB.y1 = Math.max(BB.y1, y + r);
}
const P = (x, y, z) => {
const p = [f((x - y) * C), f((x + y) * 0.5 - z)];
grow(...p);
return p;
};
const pts = (list) => list.map((p) => p.join(",")).join(" ");
function hex(c) {
const n = parseInt(c.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mix(a, b, t) {
const [x, y] = [hex(a), hex(b)];
return "#" + x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("");
}
const shade = (c, k) => (k >= 0 ? mix(c, "#ffffff", k) : mix(c, "#000000", -k));
// A seeded random, so the rocks stay put between runs.
function rng(seed) {
let s = seed * 2654435761 % 4294967296;
return () => ((s = (s * 1664525 + 1013904223) % 4294967296) / 4294967296);
}
// Materials: hull plating, dark machinery, and the tool's own color.
const HULL = "#b9c8db";
const IRON = "#3a4a63";
class Art {
constructor(color) {
this.color = color;
this.out = [];
}
add(s) {
this.out.push(s);
}
poly(list, fill, extra = "") {
this.add(`<polygon points="${pts(list)}" fill="${fill}" ${extra}/>`);
}
line(a, b, stroke, w = 2, extra = "") {
this.add(`<line x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}" stroke="${stroke}" stroke-width="${w}" stroke-linecap="round" ${extra}/>`);
}
// Box from its min corner (x, y, z), size w (X), d (Y), h (Z): top, right (+X), left (+Y).
box(x, y, z, w, d, h, col, edge = true) {
const st = edge ? `stroke="${shade(col, -0.55)}" stroke-width="1" stroke-linejoin="round"` : "";
this.poly([P(x, y + d, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x, y + d, z + h)], shade(col, -0.18), st);
this.poly([P(x + w, y, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x + w, y, z + h)], shade(col, -0.45), st);
this.poly([P(x, y, z + h), P(x + w, y, z + h), P(x + w, y + d, z + h), P(x, y + d, z + h)], shade(col, 0.22), st);
}
cbox(cx, cy, z, w, d, h, col, edge) {
this.box(cx - w / 2, cy - d / 2, z, w, d, h, col, edge);
}
// Vertical cylinder centered on (cx, cy), from z up h.
cyl(cx, cy, z, r, h, col) {
const [bx, by] = P(cx, cy, z);
const [, ty] = P(cx, cy, z + h);
const rx = f(r * C * R2), ry = f(r * R2 * 0.5);
const g = this.grad(col);
this.add(`<path d="M${bx - rx},${ty} L${bx - rx},${by} A${rx},${ry} 0 0 0 ${bx + rx},${by} L${bx + rx},${ty} Z" fill="url(#${g})" stroke="${shade(col, -0.55)}" stroke-width="1"/>`);
this.add(`<ellipse cx="${bx}" cy="${ty}" rx="${rx}" ry="${ry}" fill="${shade(col, 0.25)}" stroke="${shade(col, -0.5)}" stroke-width="1"/>`);
}
// A flat disc (ellipse) lying at height z.
disc(cx, cy, z, r, fill, extra = "") {
const [x, y] = P(cx, cy, z);
this.add(`<ellipse cx="${x}" cy="${y}" rx="${f(r * C * R2)}" ry="${f(r * R2 * 0.5)}" fill="${fill}" ${extra}/>`);
}
// Cone (drill bit) from a disc of radius r at z1 down to a point at z2.
cone(cx, cy, z1, z2, r, col) {
const [x, y1] = P(cx, cy, z1);
const [, y2] = P(cx, cy, z2);
const rx = f(r * C * R2), ry = f(r * R2 * 0.5);
this.add(`<path d="M${x - rx},${y1} A${rx},${ry} 0 0 0 ${x + rx},${y1} L${x},${y2} Z" fill="url(#${this.grad(col)})" stroke="${shade(col, -0.55)}" stroke-width="1"/>`);
}
// Horizontal shading gradient for round parts (lit from the left).
grad(col) {
const id = "g" + col.slice(1);
this.defs ??= new Map();
if (!this.defs.has(id)) {
this.defs.set(id, `<linearGradient id="${id}" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="${shade(col, -0.25)}"/><stop offset="0.3" stop-color="${shade(col, 0.3)}"/>
<stop offset="0.65" stop-color="${shade(col, -0.2)}"/><stop offset="1" stop-color="${shade(col, -0.6)}"/></linearGradient>`);
}
return id;
}
// A light: a bright core with a soft halo in the tool's color.
light(p, r, k = 1, col = this.color) {
this.add(`<circle cx="${p[0]}" cy="${p[1]}" r="${f(r * 2.6)}" fill="${col}" opacity="${f(0.35 * k * 10) / 10}" filter="url(#blur)"/>`);
this.add(`<circle cx="${p[0]}" cy="${p[1]}" r="${r}" fill="${mix(col, "#ffffff", 0.6)}"/>`);
}
// Lattice between two square frames (a tower section): legs plus X bracing.
lattice(cx, cy, z1, z2, half1, half2, col, w = 3, bays = 4) {
const corner = (h, z) => [[cx - h, cy - h, z], [cx + h, cy - h, z], [cx + h, cy + h, z], [cx - h, cy + h, z]];
const at = (t) => corner(half1 + (half2 - half1) * t, z1 + (z2 - z1) * t);
for (let i = 0; i < 4; i++) {
const j = (i + 1) % 4;
if (i === 0) continue; // the back face would only add clutter
for (let b = 0; b < bays; b++) {
const lo = at(b / bays), hi = at((b + 1) / bays);
this.line(P(...lo[i]), P(...hi[j]), shade(col, -0.25), w * 0.55);
this.line(P(...lo[j]), P(...hi[i]), shade(col, -0.25), w * 0.55);
this.line(P(...hi[i]), P(...hi[j]), shade(col, -0.1), w * 0.6);
}
}
const lo = at(0), hi = at(1);
for (let i = 0; i < 4; i++) this.line(P(...lo[i]), P(...hi[i]), i === 2 ? shade(col, 0.2) : col, w);
}
// Diagonal warning stripes on the +Y face of a box.
stripes(x, y, z, w, d, h, n, col) {
for (let i = 0; i < n; i++) {
const a = x + (w / n) * i, b = a + (w / n) * 0.5;
this.poly([P(a, y + d, z), P(b, y + d, z), P(b + h * 0.4, y + d, z + h), P(a + h * 0.4, y + d, z + h)], col, 'opacity="0.85"');
}
}
}
// ------------------------------------------------------------------ the tools
// Each draw(a, t) adds the rig's shapes around the ground origin (0, 0, 0); t is the tier.
const TOOLS = {
// Prospector Drone PD-1: a hovering quad-rotor that pulls ore up with a tractor beam.
1: {
color: "#7cf06a",
size: 0.8,
draw(a, t) {
const c = a.color, z = 92;
// tractor beam and its pool on the ground
a.disc(0, 0, 0, 34, c, `opacity="${0.18 + t * 0.05}" filter="url(#blur)"`);
const [bx, by] = P(0, 0, z), [, gy] = P(0, 0, 0);
a.add(`<polygon points="${bx - 14},${by} ${bx + 14},${by} ${bx + 34},${gy} ${bx - 34},${gy}" fill="url(#beam)" opacity="${0.45 + t * 0.08}"/>`);
const arms = [[-1, -1], [1, -1], [-1, 1], [1, 1]];
const rotor = (sx, sy) => {
const x = sx * 58, y = sy * 58;
a.cyl(x, y, z + 6, 7, 10, IRON);
a.disc(x, y, z + 17, 30, shade(c, 0.4), `opacity="0.18"`);
a.disc(x, y, z + 17, 30, "none", `stroke="${c}" stroke-width="${t >= 4 ? 3 : 1.5}" opacity="0.9"`);
a.light(P(x, y, z + 17), 2.5, 0.6);
};
// back rotors and arms first
for (const [sx, sy] of arms.slice(0, 1)) rotor(sx, sy);
for (const [sx, sy] of arms) a.line(P(0, 0, z + 10), P(sx * 58, sy * 58, z + 10), shade(HULL, -0.3), 7);
for (const [sx, sy] of arms.slice(1, 3)) rotor(sx, sy);
// body: a squat saucer with a dome
a.cyl(0, 0, z - 6, 30, 16, HULL);
a.cyl(0, 0, z + 10, 22, 6, IRON);
const [dx, dy] = P(0, 0, z + 16);
a.add(`<path d="M${dx - 22},${dy} A22,14 0 0 1 ${dx + 22},${dy} A22,9 0 0 1 ${dx - 22},${dy}" fill="${mix(c, "#0a2020", 0.55)}" stroke="${c}" stroke-width="1.5"/>`);
a.light([dx, dy - 4], 4, 0.8 + t * 0.1);
// side lamps grow in number with the tier
for (let i = 0; i < 2 + Math.min(t, 3); i++) a.light(P(26, -18 + i * 10, z + 2), 2.2, 0.7);
if (t >= 2) {
a.line(P(-8, -8, z + 18), P(-14, -14, z + 50), shade(HULL, -0.2), 2);
a.light(P(-14, -14, z + 50), 2.5, 1);
}
if (t >= 3) {
a.cyl(-6, 34, z - 14, 9, 22, c);
a.cyl(34, -6, z - 14, 9, 22, c);
}
rotor(1, 1);
},
},
// Percussion Drill PX-4: a tracked crawler with a hammer mast at its nose.
2: {
color: "#ff9a3d",
size: 0.86,
draw(a, t) {
const c = a.color;
// shock rings where the hammer bites
for (let i = 0; i < 1 + Math.ceil(t / 2); i++) {
a.disc(70, 0, 0, 22 + i * 14, "none", `stroke="${c}" stroke-width="2" opacity="${0.7 - i * 0.18}"`);
}
// tracks
a.box(-70, -48, 0, 120, 22, 26, IRON);
a.box(-70, 26, 0, 120, 22, 26, IRON);
for (let i = 0; i < 5; i++) {
const [x, y] = P(-60 + i * 25, 48, 13);
a.add(`<ellipse cx="${x}" cy="${y}" rx="8" ry="10" fill="${shade(IRON, -0.4)}" stroke="${shade(IRON, 0.3)}" stroke-width="1.5"/>`);
}
// hull, cab and the stack
a.box(-64, -40, 26, 108, 80, 26, HULL);
a.stripes(-64, -40, 26, 108, 80, 26, 7, c);
a.box(-60, -34, 52, 48, 56, 34 + (t >= 3 ? 10 : 0), HULL);
a.poly([P(-12, -26, 60), P(-12, 16, 60), P(-12, 16, 80), P(-12, -26, 80)], mix(c, "#04121a", 0.5));
a.cyl(-50, -24, 86 + (t >= 3 ? 10 : 0), 6, 26, IRON);
if (t >= 2) a.light(P(-50, -24, 118 + (t >= 3 ? 10 : 0)), 3, 0.8);
// the mast: a tall column with a piston block
const top = 150 + t * 10;
a.box(44, -12, 26, 16, 24, top - 26, shade(HULL, -0.1));
a.stripes(44, -12, 30, 16, 24, top - 34, 6, c);
a.cyl(70, 0, 30, 9, top - 40, shade(HULL, 0.1));
a.cbox(70, 0, 46 + (top - 80) * 0.45, 30, 30, 34, c);
a.cbox(70, 0, top - 10, 26, 26, 14, IRON);
a.cone(70, 0, 30, -6, 10, shade(HULL, -0.2));
a.light(P(70, 0, 2), 4, 0.6 + t * 0.1);
if (t >= 4) {
a.cyl(0, -8, 52, 12, 22, c);
a.light(P(0, -8, 74), 3, 1);
}
a.light(P(70, 0, top + 4), 3, 0.9);
},
},
// LunarLite L100: a light tripod with a spiral rotary bit.
3: {
color: "#5fd4ff",
size: 0.84,
draw(a, t) {
const c = a.color, hub = 130;
a.disc(0, 0, 0, 22, c, `opacity="${0.3 + t * 0.07}" filter="url(#blur)"`);
const legs = [[-70, -10], [20, -66], [40, 60]];
a.line(P(...legs[0], 0), P(0, 0, hub), shade(HULL, -0.35), 6);
a.line(P(...legs[1], 0), P(0, 0, hub), shade(HULL, -0.35), 6);
// the bit: a spiral auger into the ground
a.cone(0, 0, 76, -14, 14, shade(HULL, 0.05));
for (let i = 0; i < 6; i++) {
const z = 70 - i * 13, r = 14 * (z + 14) / 90;
const [x, y] = P(0, 0, z);
a.add(`<path d="M${f(x - r * 1.2)},${y} q${f(r * 1.2)},${f(r * 0.7)} ${f(r * 2.4)},-5" fill="none" stroke="${c}" stroke-width="2" opacity="0.9"/>`);
}
// motor housing
a.cyl(0, 0, 76, 16, 14, IRON);
a.cyl(0, 0, 90, 24, 40 + (t >= 3 ? 8 : 0), HULL);
a.disc(0, 0, 108, 24.5, "none", `stroke="${c}" stroke-width="3"`);
if (t >= 1) a.disc(0, 0, 98, 24.5, "none", `stroke="${c}" stroke-width="2" opacity="0.7"`);
a.cyl(0, 0, 130 + (t >= 3 ? 8 : 0), 10, 10, IRON);
a.light(P(0, 0, 146 + (t >= 3 ? 8 : 0)), 3, 0.8 + t * 0.1);
if (t >= 2) {
a.cbox(-34, 18, 0, 22, 22, 26, c);
a.line(P(-34, 18, 26), P(0, 0, 100), IRON, 2);
}
if (t >= 4) a.cyl(30, -30, 0, 9, 34, c);
a.line(P(...legs[2], 0), P(0, 0, hub), shade(HULL, -0.1), 6);
for (const l of legs) a.disc(l[0], l[1], 0, 7, IRON);
},
},
// LunarLite L300: a tall derrick with a laser cutter firing straight down.
4: {
color: "#ff5fd2",
size: 0.94,
draw(a, t) {
const c = a.color, top = 230 + t * 12;
// laser impact
a.disc(0, 0, 0, 30 + t * 4, c, `opacity="0.55" filter="url(#blur)"`);
a.disc(0, 0, 0, 12, "#ffffff", `opacity="0.8" filter="url(#blur)"`);
// power unit at the back
a.box(-96, -70, 0, 40, 36, 34, HULL);
a.stripes(-96, -70, 0, 40, 36, 34, 4, c);
a.box(-46, -50, 0, 92, 100, 10, IRON);
a.lattice(0, 0, 10, top, 42, 13, shade(HULL, -0.15), 4, 5);
// emitter and beam
const ez = 96;
const [bx, by] = P(0, 0, ez), [, gy] = P(0, 0, 0);
a.add(`<rect x="${bx - 8}" y="${by}" width="16" height="${gy - by}" fill="${c}" opacity="0.9" filter="url(#blur)"/>`);
a.add(`<rect x="${bx - 1.8}" y="${by}" width="4" height="${gy - by}" fill="#ffe6f8"/>`);
a.cbox(0, 0, ez, 26, 26, 30, HULL);
a.disc(0, 0, ez + 12, 18.5, "none", `stroke="${c}" stroke-width="3"`);
a.cone(0, 0, ez, ez - 16, 9, IRON);
if (t >= 2) a.cbox(0, 0, ez + 30, 16, 16, 24, c);
// crown block
a.cbox(0, 0, top, 34, 34, 14, c);
a.light(P(0, 0, top + 22), 3.5, 0.9 + t * 0.1);
if (t >= 3) {
a.cyl(56, 30, 0, 12, 44, c);
a.light(P(56, 30, 48), 3, 0.8);
}
if (t >= 4) {
for (const z of [ez + 70, ez + 110]) a.disc(0, 0, z, 30, "none", `stroke="${c}" stroke-width="2.5" opacity="0.85"`);
}
},
},
// Lunar Regolith Extractor LRE-3000: a bucket-wheel excavator on tracks.
5: {
color: "#ffd24a",
size: 0.95,
draw(a, t) {
const c = a.color;
// counterweight boom (behind)
a.line(P(-10, 0, 80), P(-120, 0, 106), shade(HULL, -0.35), 12);
a.line(P(-30, 0, 120), P(-120, 0, 122), shade(HULL, 0.05), 3);
a.cbox(-120, 0, 92, 30, 34, 30, IRON);
// tracks and turntable
a.box(-60, -56, 0, 100, 26, 24, IRON);
a.box(-60, 30, 0, 100, 26, 24, IRON);
a.cyl(-10, 0, 24, 40, 10, IRON);
// superstructure
a.box(-50, -34, 34, 74, 68, 46, HULL);
a.stripes(-50, -34, 34, 74, 68, 16, 6, c);
a.box(-36, -24, 80, 34, 40, 26 + (t >= 3 ? 10 : 0), HULL);
a.poly([P(-2, -18, 88), P(-2, 10, 88), P(-2, 10, 100), P(-2, -18, 100)], mix(c, "#141004", 0.4));
// boom out to the wheel
const wx = 130, wz = 74;
a.line(P(10, 0, 70), P(wx, 0, wz + 6), shade(HULL, -0.1), 12);
a.line(P(10, 0, 116), P(wx - 6, 0, wz + 26), shade(HULL, 0.1), 3);
a.line(P(-20, 0, 110), P(10, 0, 116), shade(HULL, 0.1), 3);
// the bucket wheel, facing the viewer
const [x, y] = P(wx, 0, wz);
const R = 52 + t * 3, n = 8 + t * 2;
grow(x, y, R + 9);
a.add(`<circle cx="${x}" cy="${y}" r="${R}" fill="none" stroke="${shade(HULL, -0.2)}" stroke-width="9"/>`);
a.add(`<circle cx="${x}" cy="${y}" r="${R}" fill="none" stroke="${c}" stroke-width="2"/>`);
for (let i = 0; i < n; i++) {
const ang = (i / n) * Math.PI * 2;
const ex = x + Math.cos(ang) * R, ey = y + Math.sin(ang) * R;
a.line([x, y], [f(ex), f(ey)], shade(HULL, -0.3), 2.5);
a.add(`<rect x="${f(ex - 8)}" y="${f(ey - 7)}" width="16" height="14" rx="3" fill="${c}" stroke="${shade(c, -0.6)}" stroke-width="1" transform="rotate(${f((ang * 180) / Math.PI + 90)} ${f(ex)} ${f(ey)})"/>`);
}
a.add(`<circle cx="${x}" cy="${y}" r="12" fill="${IRON}" stroke="${c}" stroke-width="2"/>`);
a.light([x, y], 4, 0.7 + t * 0.1);
if (t >= 2) a.light(P(-36, -24, 108 + (t >= 3 ? 10 : 0)), 3, 0.9);
if (t >= 4) {
a.cyl(22, 40, 34, 10, 30, c);
a.light(P(22, 40, 66), 2.5, 1);
}
// dust thrown up by the wheel
const r = rng(5);
for (let i = 0; i < 6 + t * 3; i++) {
a.add(`<circle cx="${f(x - 20 + r() * 70)}" cy="${f(y + R * 0.4 + r() * 50)}" r="${f(1 + r() * 2.5)}" fill="${mix(c, "#ffffff", 0.3)}" opacity="${f(0.3 + r() * 0.5)}"/>`);
}
},
},
// Electrostatic Dust Excavator EDE-700: a coil tower of charged plates with arcs.
6: {
color: "#a98bff",
size: 0.97,
draw(a, t) {
const c = a.color, plates = 3 + Math.min(t, 3);
a.disc(0, 0, 0, 80, c, `opacity="${0.15 + t * 0.05}" filter="url(#blur)"`);
// base platform with feet
a.cyl(0, 0, 0, 62, 16, IRON);
for (const [x, y] of [[-60, 0], [0, -60], [60, 0], [0, 60]]) a.cbox(x, y, 0, 18, 18, 22, HULL);
a.cyl(0, 0, 16, 30, 26, HULL);
// the column and its plates, widest at the bottom
const h = 60 + plates * 30;
a.cyl(0, 0, 40, 13, h, shade(HULL, -0.05));
const r = rng(6);
for (let i = 0; i < plates; i++) {
const z = 58 + i * 30, rad = 54 - i * 5;
a.cyl(0, 0, z, rad, 6, HULL);
a.disc(0, 0, z + 6, rad + 0.5, "none", `stroke="${c}" stroke-width="2.5"`);
a.disc(0, 0, z + 6, rad, c, `opacity="0.25" filter="url(#blur)"`);
a.cyl(0, 0, z + 6, 14, 24, shade(HULL, -0.05));
}
// arcs between the plates
for (let i = 0; i < Math.min(plates - 1, 1 + t); i++) {
const z = 64 + i * 30, side = i % 2 ? 1 : -1;
let [x, y] = P(side * 36, side * -10, z);
let d = `M${x},${y}`;
for (let k = 0; k < 4; k++) d += ` L${f(x + side * (4 + r() * 10))},${f(y - 7 - k * 6)}`;
a.add(`<path d="${d}" fill="none" stroke="${c}" stroke-width="5" opacity="0.5" filter="url(#blur)"/>`);
a.add(`<path d="${d}" fill="none" stroke="#f2ecff" stroke-width="1.5"/>`);
}
// the top sphere
const [tx, ty] = P(0, 0, 40 + h + 14);
grow(tx, ty, 16 + t);
a.add(`<circle cx="${tx}" cy="${ty}" r="${16 + t}" fill="url(#orb)" stroke="${c}" stroke-width="1.5"/>`);
a.light([tx, ty], 5, 0.8 + t * 0.12);
// dust rising into the field
for (let i = 0; i < 8 + t * 4; i++) {
const ang = r() * Math.PI * 2, rr = 50 + r() * 50;
const [px, py] = P(Math.cos(ang) * rr, Math.sin(ang) * rr, r() * h);
a.add(`<circle cx="${px}" cy="${py}" r="${f(1 + r() * 2)}" fill="${mix(c, "#ffffff", 0.4)}" opacity="${f(0.3 + r() * 0.6)}"/>`);
}
},
},
// Lunar Core Extractor LCE-360: a heavy rig on legs over a glowing bore, with a tall tower.
7: {
color: "#ff5a3c",
size: 1,
draw(a, t) {
const c = a.color;
// the bore under the rig
a.disc(0, 0, 0, 52, c, `opacity="0.75" filter="url(#blur)"`);
a.disc(0, 0, 0, 30, "#ffd0a0", `opacity="0.85" filter="url(#blur)"`);
a.disc(0, 0, 0, 44, "none", `stroke="${shade(c, 0.2)}" stroke-width="3"`);
// legs
for (const [x, y] of [[-80, -80], [80, -80], [-80, 80]]) a.cbox(x, y, 0, 14, 14, 56, IRON);
// drill string into the bore
a.cyl(0, 0, -4, 8, 60, shade(HULL, -0.2));
// deck
a.box(-92, -92, 56, 184, 184, 16, HULL);
a.stripes(-92, -92, 56, 184, 184, 16, 12, c);
// tanks and a crane
a.cyl(-60, 60, 72, 18, 40, HULL);
a.disc(-60, 60, 96, 18.5, "none", `stroke="${c}" stroke-width="2.5"`);
if (t >= 2) {
a.cyl(60, -60, 72, 16, 34, HULL);
a.disc(60, -60, 92, 16.5, "none", `stroke="${c}" stroke-width="2.5"`);
}
a.box(30, 30, 72, 52, 44, 40 + (t >= 3 ? 14 : 0), HULL);
a.poly([P(82, 38, 92), P(82, 66, 92), P(82, 66, 104), P(82, 38, 104)], mix(c, "#1a0604", 0.4));
const top = 260 + t * 12;
a.lattice(0, 0, 72, top, 36, 12, shade(HULL, -0.1), 4, 6);
a.cbox(0, 0, top, 30, 30, 12, c);
a.light(P(0, 0, top + 20), 4, 0.9 + t * 0.1);
a.line(P(-70, -40, 72), P(-110, 10, 170 + t * 6), shade(HULL, -0.2), 5);
a.line(P(-110, 10, 170 + t * 6), P(-110, 10, 120), IRON, 1.5);
a.cbox(-110, 10, 108, 10, 10, 12, c);
if (t >= 4) for (const z of [140, 200]) a.disc(0, 0, z, 26, "none", `stroke="${c}" stroke-width="2.5" opacity="0.9"`);
// the front leg last
a.cbox(80, 80, 0, 14, 14, 56, IRON);
a.light(P(92, 92, 72), 3, 0.9);
},
},
};
// ------------------------------------------------------------------ scene
function svg(id, tier, maxTier) {
const tool = TOOLS[id];
const c = tool.color;
const gx = 256, gy = 338; // ground origin on the image; deploy cards crop to about y 51..392
// Fit the top tier into the frame (up to y 72, 440 wide); lower tiers are smaller, and
// smaller tools stay smaller (tool.size).
BB = { x0: 0, x1: 0, y0: 0, y1: 0 };
tool.draw(new Art(c), maxTier);
const box = BB;
BB = null;
const fit = Math.min((gy - 72) / -box.y0, 440 / (box.x1 - box.x0), 1.8);
const a = new Art(c);
tool.draw(a, tier);
const k = maxTier ? tier / maxTier : 0;
const scale = fit * tool.size * (0.8 + 0.2 * k);
const shift = f(-((box.x0 + box.x1) / 2) * scale);
// The ground disc, rocks, and a ring of tier segments around the base.
const r = rng(id * 31 + 7);
let rocks = "";
for (let i = 0; i < 26; i++) {
const ang = r() * Math.PI * 2, d = 0.25 + r() * 0.75;
const x = f(Math.cos(ang) * 190 * d), y = f(Math.sin(ang) * 50 * d);
const s = f(2 + r() * 6);
rocks += `<ellipse cx="${x}" cy="${y}" rx="${s}" ry="${f(s * 0.6)}" fill="#2a3446" opacity="${f(0.5 + r() * 0.5)}"/>`;
}
const rx = 168, ry = 44;
const circ = Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry)));
const seg = circ / Math.max(maxTier, 1);
const lit = Array.from({ length: maxTier }, (_, i) => (i < tier ? `${f(seg * 0.82)} ${f(seg * 0.18)}` : `0 ${f(seg)}`)).join(" ");
const ring = `<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${c}" stroke-opacity="0.18" stroke-width="3" stroke-dasharray="${f(seg * 0.82)} ${f(seg * 0.18)}" transform="rotate(180)"/>`
+ (tier ? `<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${c}" stroke-width="7" opacity="0.6" filter="url(#blur)" stroke-dasharray="${lit}" transform="rotate(180)"/>
<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${mix(c, "#ffffff", 0.35)}" stroke-width="3" stroke-dasharray="${lit}" transform="rotate(180)"/>` : "");
// Sparkles at the top tier.
let sparkles = "";
if (tier === maxTier && maxTier > 0) {
const s = rng(id * 13);
for (let i = 0; i < 14; i++) {
const x = f(80 + s() * 352), y = f(70 + s() * 240), q = f(2 + s() * 4);
sparkles += `<path d="M${x},${y - q * 2} L${x + q * 0.4},${y} L${x},${y + q * 2} L${x - q * 0.4},${y} Z M${x - q * 2},${y} L${x},${y + q * 0.4} L${x + q * 2},${y} L${x},${y - q * 0.4} Z" fill="${mix(c, "#ffffff", 0.6)}" opacity="${f(0.4 + s() * 0.6)}"/>`;
}
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<defs>
<radialGradient id="bg" cx="0.5" cy="0.45" r="0.75"><stop offset="0" stop-color="#0e2446"/><stop offset="1" stop-color="#040a16"/></radialGradient>
<radialGradient id="aura" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="${c}" stop-opacity="${f((0.16 + 0.3 * k) * 100) / 100}"/><stop offset="1" stop-color="${c}" stop-opacity="0"/></radialGradient>
<radialGradient id="ground" cx="0.5" cy="0.4" r="0.6"><stop offset="0" stop-color="#1c2638"/><stop offset="0.75" stop-color="#121a28"/><stop offset="1" stop-color="#0a101c" stop-opacity="0"/></radialGradient>
<linearGradient id="beam" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${c}" stop-opacity="0.9"/><stop offset="1" stop-color="${c}" stop-opacity="0.15"/></linearGradient>
<radialGradient id="orb" cx="0.35" cy="0.35" r="0.7"><stop offset="0" stop-color="#ffffff"/><stop offset="0.4" stop-color="${mix(c, "#ffffff", 0.4)}"/><stop offset="1" stop-color="${shade(c, -0.5)}"/></radialGradient>
<filter id="blur" x="-1" y="-1" width="3" height="3"><feGaussianBlur stdDeviation="6"/></filter>
<filter id="halo" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="${f(2 + 4 * k)}" result="b"/><feFlood flood-color="${c}" flood-opacity="${f((0.25 + 0.5 * k) * 100) / 100}"/><feComposite in2="b" operator="in"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter>
${[...(a.defs?.values() ?? [])].join("\n ")}
</defs>
<rect width="${SIZE}" height="${SIZE}" fill="url(#bg)"/>
<g stroke="#5fd4ff" stroke-opacity="0.06" stroke-width="1">${Array.from({ length: 15 }, (_, i) => `<line x1="${i * 36 + 4}" y1="0" x2="${i * 36 + 4}" y2="${SIZE}"/><line x1="0" y1="${i * 36 + 4}" x2="${SIZE}" y2="${i * 36 + 4}"/>`).join("")}</g>
<ellipse cx="${gx}" cy="${gy - 110}" rx="${f(150 + 60 * k)}" ry="${f(150 + 60 * k)}" fill="url(#aura)"/>
<g transform="translate(${gx} ${gy})">
<ellipse rx="230" ry="70" fill="url(#ground)"/>
${rocks}
${ring}
<ellipse rx="${f(110 * scale)}" ry="${f(38 * scale)}" fill="#000" opacity="0.45" filter="url(#blur)"/>
<g transform="translate(${shift} 0) scale(${f(scale * 1000) / 1000})" filter="url(#halo)">
${a.out.join("\n ")}
</g>
</g>
${sparkles}
</svg>`;
}
// ------------------------------------------------------------------ main
export function tierImage(image, tier) {
if (!tier) return image;
const dot = image.lastIndexOf(".");
return `${image.slice(0, dot)}-t${tier}${image.slice(dot)}`;
}
const game = JSON.parse(readFileSync("config/game.json", "utf8"));
const svgDir = process.argv.indexOf("--svg") > 0 ? process.argv[process.argv.indexOf("--svg") + 1] : null;
const tmp = mkdtempSync(join(tmpdir(), "tool-art-"));
if (svgDir) mkdirSync(svgDir, { recursive: true });
try {
for (const item of game.storeItems) {
if (!TOOLS[item.id]) {
console.warn(`no art for store item ${item.id} (${item.name}), skipped`);
continue;
}
const maxTier = item.upgrades?.length ?? 0;
for (let tier = 0; tier <= maxTier; tier++) {
const name = tierImage(item.image, tier);
const src = join(svgDir ?? tmp, name.replace(/\.\w+$/, ".svg"));
writeFileSync(src, svg(item.id, tier, maxTier));
const png = join(tmp, "out.png");
execFileSync("rsvg-convert", ["-w", String(SIZE), "-h", String(SIZE), "-o", png, src]);
const dst = join(OUT, name);
const opts = name.endsWith(".jpg") ? ["-strip", "-quality", "88", "-interlace", "Plane"] : ["-strip", "-colors", "256", "PNG8:" + dst];
execFileSync("magick", [png, ...opts, ...(name.endsWith(".jpg") ? [dst] : [])]);
console.log(dst);
}
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
+9 -47
View File
@@ -10,7 +10,6 @@ import (
"log"
"net"
"net/http"
"net/netip"
"os"
"regexp"
"strconv"
@@ -219,35 +218,21 @@ func (l *rateLimiter) allow(r *http.Request) bool {
}
// clientIP is the connection's address, or with TRUST_PROXY=1 the address the proxy saw
// (the last X-Forwarded-For entry; earlier ones are client-supplied), normalised.
// (the last X-Forwarded-For entry; earlier ones are client-supplied).
func clientIP(r *http.Request) string {
if os.Getenv("TRUST_PROXY") == "1" {
if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
parts := strings.Split(xff[len(xff)-1], ",")
if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
return normalIP(ip)
return ip
}
}
}
return normalIP(r.RemoteAddr)
}
// normalIP is an address (with or without a port) as one key per client: IPv4-mapped IPv6
// as its IPv4, no zone, and every loopback address (127.0.0.0/8, ::1) as "loopback".
// Anything unparsable is kept as it is.
func normalIP(s string) string {
if host, _, err := net.SplitHostPort(s); err == nil {
s = host
}
ip, err := netip.ParseAddr(s)
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return s
return r.RemoteAddr
}
ip = ip.Unmap().WithZone("")
if ip.IsLoopback() {
return "loopback"
}
return ip.String()
return host
}
func serverError(w http.ResponseWriter, err error) {
@@ -333,12 +318,12 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
serverError(w, err)
return
}
state, v, err := loadView(r.Context(), s.db, userID, viewFromForm(r.FormValue))
state, err := loadState(r.Context(), s.db, userID)
if err != nil {
serverError(w, err)
return
}
page := state.page(v)
page := state.page(viewFromForm(r.FormValue).resolve(state))
if err := s.loadRanking(r.Context(), page); err != nil {
serverError(w, err)
return
@@ -542,29 +527,6 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
if err = hireAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " hired"
}
case "seat-advisor":
// Puts a hired advisor (advisor) in an empty seat (seat, from 1).
name := get("advisor")
seat, _ := strconv.Atoi(get("seat"))
if err = seatAdvisor(ctx, s.db, userID, name, seat); err == nil {
message = fmt.Sprintf("%s takes seat %d", name, seat)
}
case "unseat-advisor":
name := get("advisor")
if err = unseatAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " left their seat"
}
case "swap-advisors":
// Exchanges the seats of two hired advisors (advisor, with).
name, other := get("advisor"), get("with")
if err = swapAdvisors(ctx, s.db, userID, name, other); err == nil {
message = fmt.Sprintf("%s and %s swapped seats", name, other)
}
case "dismiss-advisor":
name := get("advisor")
if err = dismissAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " dismissed"
}
case "buy-seat":
var n int
if n, err = buyAdvisorSeat(ctx, s.db, userID); err == nil {
@@ -620,13 +582,13 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
return
}
state, v, err := loadView(ctx, s.db, userID, res.View)
state, err := loadState(ctx, s.db, userID)
if err != nil {
serverError(w, err)
return
}
toast := res.toast(state)
page := state.page(v)
page := state.page(res.View.resolve(state))
if err := s.loadRanking(ctx, page); err != nil {
serverError(w, err)
return
-18
View File
@@ -1,18 +0,0 @@
-- Advisors sit in numbered seats (advisors.go); seat null is hired but not seated. Hires
-- from before take seats 1, 2, ... in hiring order, once: only when the column is added,
-- so advisors unseated since stay unseated when this runs again.
do $$
begin
if not exists (select 1 from information_schema.columns
where table_name = 'advisor_hire' and column_name = 'seat') then
alter table advisor_hire add column seat smallint;
update advisor_hire set seat = numbered.n
from (select user_id, advisor, row_number() over (partition by user_id order by hired_at, advisor) as n
from advisor_hire) numbered
where advisor_hire.user_id = numbered.user_id and advisor_hire.advisor = numbered.advisor;
end if;
end
$$;
create unique index if not exists advisor_seat on advisor_hire (user_id, seat) where seat is not null;
+1 -4
View File
@@ -237,13 +237,10 @@ create index attachment_user on attachment (user_id);
create unique index attachment_once on attachment (inventory_item_id, item)
where inventory_item_id is not null;
-- Advisors hired (advisors.go), by config name. Seated ones (seat 1..users.advisor_seats)
-- give their bonus; seat null: hired but not seated.
-- Advisors hired (advisors.go), by config name.
create table advisor_hire (
user_id uuid not null references users(id) on delete cascade,
advisor text not null,
hired_at timestamp with time zone not null default now(),
seat smallint,
primary key (user_id, advisor)
);
create unique index advisor_seat on advisor_hire (user_id, seat) where seat is not null;
+3 -48
View File
@@ -32,9 +32,8 @@ 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()} {
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} {
for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
v = v.resolve(s)
p := s.page(v)
if v.Section == "leaderboard" {
@@ -54,7 +53,6 @@ func TestTemplates(t *testing.T) {
render("regions", p)
}
// The buy modal with no moons or only empty wells.
s.pagePastInMemory(1)
p := s.page(View{Section: "store"}.resolve(s))
p.OOB = true
p.Modal = &BuyModal{Item: &s.Config.StoreItems[0], NoMoons: true, AllEmpty: true}
@@ -168,18 +166,6 @@ func testViews(s *State) []View {
for _, r := range s.Refined {
views = append(views, View{Section: "market", Pick: r.Item})
}
for _, h := range s.Hires {
views = append(views, View{Section: "advisors", Adv: h.Name})
}
// The Past tab, its pages (one past the last: clamped) and a past moon from another page.
for page := range 4 {
views = append(views, View{Section: "moons", Past: true, Page: page})
}
for _, m := range s.Moons {
if m.Finished {
views = append(views, View{Section: "moons", Past: true, Page: 2, Moon: m.ID})
}
}
return views
}
@@ -279,40 +265,9 @@ func veteranState() *State {
s.Fitted[s.Tools[0].ID] = []string{c.Attachments.Items[0].Name}
s.Spare[c.Attachments.Items[1].Name] = 2
}
// Advisors: one seated, one hired but not seated, a free seat.
if len(c.Advisors.Items) > 1 {
s.Hires = []AdvisorHire{{Name: c.Advisors.Items[0].Name, Seat: 1}, {Name: c.Advisors.Items[1].Name}}
if len(c.Advisors.Items) > 0 {
s.Advisors = []string{c.Advisors.Items[0].Name}
s.AdvisorSeats = 2
s.AdvisorSeats = 1
}
// More past moons than fit a page (pastPerPage).
for n := range pastPerPage + 3 {
s.Moons = append(s.Moons, Moon{ID: testID("p", n), Name: fmt.Sprintf("Past %d", n), Kind: "Old",
Wells: []Well{{ID: testID("y", n), Resource: wells[0].Resource}}, CreatedAt: now.Add(-72 * time.Hour),
Finished: true, FinishedAt: now.Add(-time.Duration(n+2) * time.Hour),
Mined: []ResourceBalance{{Resource: wells[0].Resource, Balance: float64(n)}}})
}
s.pagePastInMemory(1)
return s
}
// fullBoardState is a veteran whose seats are all taken, with advisors waiting for one
// (and one no longer in the config).
func fullBoardState() *State {
c := cfg()
s := veteranState()
s.UserID = testID("u", 3)
s.Hires, s.Advisors = nil, nil
s.AdvisorSeats = 1 + len(c.Advisors.SeatPrices)
for i, a := range c.Advisors.Items {
seat := i + 1
if seat > s.AdvisorSeats {
seat = 0
} else {
s.Advisors = append(s.Advisors, a.Name)
}
s.Hires = append(s.Hires, AdvisorHire{Name: a.Name, Seat: seat})
}
s.Hires = append(s.Hires, AdvisorHire{Name: "Retired Advisor"})
return s
}
+2 -58
View File
@@ -17,17 +17,10 @@ userId=:user_id
# A view: s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon, moon=<id>, well=<id>,
# drill=<tool id> (equipment: the tool's attachment panel),
# pick=<resource or refined item> (market: the selected quote), list=past (moons: the Past tab,
# the only way a mined-out moon shows), page=<n> (with list=past: 10 past moons a page, 1 the
# most recent), adv=<advisor name> (advisors: a hired advisor's panel)
# the only way a mined-out moon shows)
GET :host/?s=store
:headers
GET :host/?list=past&page=2
:headers
GET :host/?s=advisors&adv=Ada+Voss
:headers
# Buy a moon
POST :host/act/buy-moon
:headers
@@ -125,26 +118,6 @@ advisor=Mira+Castellanos
POST :host/act/buy-seat
:headers
# Put a hired advisor in an empty seat (seat from 1; moves them if seated elsewhere)
POST :host/act/seat-advisor
:headers
advisor=Mira+Castellanos&seat=2
# Take a seated advisor off their seat (stays hired, no bonus)
POST :host/act/unseat-advisor
:headers
advisor=Mira+Castellanos
# Swap two hired advisors' seats (one may be unseated)
POST :host/act/swap-advisors
:headers
advisor=Mira+Castellanos&with=Ada+Voss
# Dismiss a hired advisor (frees their seat, nothing refunded)
POST :host/act/dismiss-advisor
:headers
advisor=Mira+Castellanos
# Start over as a new player (sets a new cookie)
POST :host/act/new-game
:headers
@@ -204,8 +177,7 @@ Content-Type: application/json
# Other sections' actions (clientSeq is echoed back): buy-tool (id), upgrade (tool),
# sell-tool (tool), buy-attachment (item), equip/unequip (item, to), sell-attachment (item),
# refine (recipe, qty or all), collect (job or none), buy-slot, hire (advisor), buy-seat,
# seat-advisor (advisor, seat), unseat-advisor (advisor), swap-advisors (advisor, with),
# dismiss-advisor (advisor), sell-moon (sell-moon)
# sell-moon (sell-moon)
POST :host/api/act/buy-tool
X-MM-User: :user_id
Content-Type: application/json
@@ -230,34 +202,6 @@ Content-Type: application/json
{"advisor": "Ada Voss", "s": "advisors"}
POST :host/api/act/seat-advisor
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "seat": 2, "s": "advisors"}
POST :host/api/act/unseat-advisor
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "s": "advisors"}
POST :host/api/act/swap-advisors
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "with": "Mira Castellanos", "s": "advisors"}
POST :host/api/act/dismiss-advisor
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "s": "advisors"}
# Past moons, a page at a time (state.pastPage)
GET :host/api/state?list=past&page=2
X-MM-User: :user_id
POST :host/api/act/sell-moon
X-MM-User: :user_id
Content-Type: application/json
+15 -84
View File
@@ -138,24 +138,21 @@ func formatCycle(secs int) string {
// ------------------------------------------------------------------ view
// View is what the player is looking at, carried in the URL:
// /?s=<section>&moon=<id>&well=<id>&list=past&page=<n>.
// /?s=<section>&moon=<id>&well=<id>&list=past.
type View struct {
Section string // moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon
Moon string
Past bool // the player opened the moon list's Past tab: a past moon may be on screen
Page int // the page of past moons (1: the most recent), from page=
Well string
Pick string // the selected resource or refined item (market section)
Drill string // the selected tool (equipment section)
Adv string // the selected hired advisor (advisors section), from adv=
}
var sections = map[string]bool{"moons": true, "equipment": true, "market": true, "store": true, "help": true, "refinery": true, "advisors": true, "leaderboard": true, "new-moon": true}
func viewFromForm(get func(string) string) View {
page, _ := strconv.Atoi(get("page"))
return View{Section: get("s"), Moon: get("moon"), Well: get("well"), Pick: get("pick"),
Drill: get("drill"), Past: get("list") == "past", Page: page, Adv: get("adv")}
Drill: get("drill"), Past: get("list") == "past"}
}
// resolve fills in defaults and drops ids that don't belong to the player.
@@ -173,9 +170,13 @@ func (v View) resolve(s *State) View {
if moon != nil && moon.Finished != v.Past {
moon = nil
}
if moon == nil && v.Past && len(s.PastIDs) > 0 {
// The Past tab: the last moon finished (on its page).
moon = s.moon(s.PastIDs[0])
if moon == nil && v.Past {
// The Past tab: the last moon finished.
for i := range s.Moons {
if s.Moons[i].Finished && (moon == nil || s.Moons[i].FinishedAt.After(moon.FinishedAt)) {
moon = &s.Moons[i]
}
}
}
if moon == nil {
v.Past = false
@@ -200,10 +201,6 @@ func (v View) resolve(s *State) View {
v.Drill = ""
}
v.Drill = s.idleCopy(v.Drill)
if v.Section != "advisors" || s.hire(v.Adv) == nil {
v.Adv = ""
}
v.Page = s.PastPage
return v
}
@@ -217,9 +214,6 @@ func (v View) URL() string {
}
if v.Past && v.Section == "moons" {
q.Set("list", "past")
if v.Page > 1 {
q.Set("page", strconv.Itoa(v.Page))
}
}
if v.Well != "" {
q.Set("well", v.Well)
@@ -230,9 +224,6 @@ func (v View) URL() string {
if v.Drill != "" {
q.Set("drill", v.Drill)
}
if v.Adv != "" {
q.Set("adv", v.Adv)
}
if len(q) == 0 {
return "/"
}
@@ -248,20 +239,15 @@ func (v View) ToMoon(id string) string {
return View{Section: "moons", Moon: id}.URL()
}
// ToPastMoon opens a finished moon from the Past tab (on the current page of it).
// ToPastMoon opens a finished moon from the Past tab.
func (v View) ToPastMoon(id string) string {
return View{Section: "moons", Moon: id, Past: true, Page: v.Page}.URL()
return View{Section: "moons", Moon: id, Past: true}.URL()
}
func (v View) ToWell(id string) string {
return View{Section: "moons", Moon: v.Moon, Well: id}.URL()
}
// ToAdvisor opens a hired advisor's panel in the advisors section.
func (v View) ToAdvisor(name string) string {
return View{Section: "advisors", Moon: v.Moon, Adv: name}.URL()
}
// ToDrill opens a tool's panel in the equipment section.
func (v View) ToDrill(id string) string {
return View{Section: "equipment", Moon: v.Moon, Drill: id}.URL()
@@ -365,28 +351,10 @@ func (p *Page) ActiveURL() string {
}
func (p *Page) PastURL() string {
if p.S.PastTotal == 0 {
if len(p.Past) == 0 {
return ""
}
return View{Section: "moons", Past: true}.URL()
}
// PastPaging is the Past tab's paging: the page shown, how many there are, and links to
// the newer and older pages ("" at either end).
type PastPaging struct {
Page, Pages, Total int
Newer, Older string
}
func (p *Page) PastPaging() PastPaging {
pg := PastPaging{Page: p.S.PastPage, Pages: p.S.pastPages(), Total: p.S.PastTotal}
if pg.Page > 1 {
pg.Newer = View{Section: "moons", Past: true, Page: pg.Page - 1}.URL()
}
if pg.Page < pg.Pages {
pg.Older = View{Section: "moons", Past: true, Page: pg.Page + 1}.URL()
}
return pg
return p.V.ToPastMoon(p.Past[0].ID)
}
type MoonDetail struct {
@@ -774,9 +742,7 @@ func (s *State) page(v View) *Page {
row.Pending = endsAttr(pending)
p.Ready += row.Ready
if m.Finished {
if slices.Contains(s.PastIDs, m.ID) {
p.Past = append(p.Past, row)
}
} else {
p.Moons = append(p.Moons, row)
}
@@ -786,10 +752,7 @@ func (s *State) page(v View) *Page {
}
p.Pending = endsAttr(allPending)
p.PastView = v.Past
// In the page's order (most recently finished first).
sort.SliceStable(p.Past, func(i, j int) bool {
return slices.Index(s.PastIDs, p.Past[i].ID) < slices.Index(s.PastIDs, p.Past[j].ID)
})
sort.SliceStable(p.Past, func(i, j int) bool { return p.Past[i].FinishedAt.After(p.Past[j].FinishedAt) })
switch v.Section {
case "moons":
@@ -934,14 +897,7 @@ func (s *State) page(v View) *Page {
p.Refinery = s.refineryPage()
case "advisors":
p.Advisors = s.advisorsPage(v)
// The advisor panel goes in the side panel, under the current moon.
if p.Advisors.Panel != nil {
p.Scene.Select = map[string]string{}
if v.Moon != "" {
p.Scene.Select["moonId"] = v.Moon
}
}
p.Advisors = s.advisorsPage()
}
return p
}
@@ -1394,31 +1350,6 @@ func (r WellRow) Full() bool {
return r.Supply > 0 && (r.Demand > 0 && r.Demand >= r.Supply-1e-9 || r.Remain <= 1e-9 && r.Taken() > 0)
}
// StatusText is the well note's lead (template "well-note"): "Over-drilled — next claims
// will come up short", "Fully committed" or "".
func (r WellRow) StatusText() string {
switch {
case r.Over():
return "Over-drilled — next claims will come up short"
case r.Full():
return "Fully committed"
}
return ""
}
// NoteText is the rest of the well note, as the template words it: "47 left · 2 committed ·
// 45 free after" or "… · runs dry after 3 claims"; "" when no drill wants anything from it.
func (r WellRow) NoteText() string {
if r.Demand == 0 {
return ""
}
t := formatMoney(r.Supply) + " left · " + formatMoney(r.Demand) + " committed"
if r.DryAfter > 0 {
return t + " · runs dry after " + plural(r.DryAfter, "claim")
}
return t + " · " + formatMoney(r.Remain) + " free after"
}
// BarStack is a wells table row's timer bars (template "drill-stack"): the first drills,
// oldest first, and how many it leaves out. Key makes the bars' ids unique on the page
// (the morph matches them per drill).
+1 -82
View File
@@ -1288,74 +1288,6 @@ a.htmx-request::after {
.advisor-card .card-buy.ready {
text-align: center;
}
/* Advisor seats (who sits where), the hired advisors without a seat, and the advisor
panel's portrait. */
.adv-seats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
gap: 12px;
margin: 16px 0;
padding: 0;
list-style: none;
}
.adv-seat-in {
display: flex;
align-items: center;
gap: 12px;
min-height: 76px;
padding: 10px 12px;
border: 1px solid rgb(var(--ready-rgb) / 0.4);
background: var(--panel);
color: inherit;
text-decoration: none;
transition: border-color 0.15s;
}
.adv-seat.is-empty .adv-seat-in {
border: 1px dashed var(--edge);
background: transparent;
}
a.adv-seat-in:hover,
a.adv-seat-in[aria-current] {
border-color: rgb(var(--ready-rgb) / 0.9);
}
.adv-seat-in img {
width: 54px;
height: 54px;
object-fit: contain;
background: var(--bg);
}
.adv-bench-title {
margin-top: 8px;
}
.adv-bench {
margin-bottom: 16px;
}
.adv-bench a {
color: inherit;
text-decoration: none;
}
.adv-portrait {
display: flex;
justify-content: center;
height: 140px;
margin: 10px 0;
background: var(--bg);
}
.adv-portrait img {
height: 100%;
aspect-ratio: 1;
object-fit: contain;
}
.bonus-chips {
display: flex;
flex-wrap: wrap;
@@ -4638,19 +4570,6 @@ textarea.eco-input {
filter: none;
}
.past-paging {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
text-align: center;
}
.past-paging [aria-disabled] {
opacity: 0.4;
pointer-events: none;
}
.past-moon .total td {
color: var(--text);
font-weight: 600;
@@ -4997,7 +4916,7 @@ textarea.eco-input {
gap: 12px;
}
.reveal-foot .btn:first-child {
.reveal-foot .btn {
margin-left: auto;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 44 KiB

+1 -5
View File
@@ -168,7 +168,7 @@ function play(kind, toast, prev, at) {
});
}
if (["buy-tool", "upgrade", "buy-moon", "buy-attachment", "equip", "hire", "buy-seat", "seat-advisor", "swap-advisors"].includes(kind)) {
if (["buy-tool", "upgrade", "buy-moon", "buy-attachment", "equip", "hire", "buy-seat"].includes(kind)) {
if (at) burst(at.x, at.y, [CYAN, CYAN_HI], 34);
// The page may change under a new moon, so sweep its list entry instead.
const moon = kind === "buy-moon" && document.querySelector("#moonlist .moon[aria-current]");
@@ -210,10 +210,6 @@ function play(kind, toast, prev, at) {
equip: "upgrade",
unequip: "toggle",
hire: "confirm",
"seat-advisor": "upgrade",
"unseat-advisor": "toggle",
"swap-advisors": "toggle",
"dismiss-advisor": "powerdown",
"buy-seat": "buy",
"buy-slot": "buy",
upgrade: "upgrade",
+2 -2
View File
@@ -120,9 +120,9 @@ window.addEventListener("popstate", () => {
// Actions keep the current view: send it along unless the button set its own.
document.body.addEventListener("htmx:configRequest", (event) => {
if (event.detail.verb !== "post") return;
const { s, moon, well, drill, list, page, adv } = view();
const { s, moon, well, drill, list } = view();
const params = event.detail.parameters;
for (const [key, value] of Object.entries({ s, moon, well, drill, list, page, adv })) {
for (const [key, value] of Object.entries({ s, moon, well, drill, list })) {
// params is a FormData proxy: `in` never sees its keys, a missing one reads undefined.
if (params[key] === undefined && value) params[key] = value;
}
+1 -16
View File
@@ -11,8 +11,7 @@
// balance down ("mm:revealed").
//
// A click or key skips ahead: during the charge to the burst, during the card to its end
// state; once the card is complete it closes it. "Buy another" closes it and buys the next
// moon (buy-moon), whose response plays a new reveal. With prefers-reduced-motion the moon
// state; once the card is complete it closes it. With prefers-reduced-motion the moon
// swaps in and the card fades.
import * as sfx from "./sfx.js";
@@ -78,7 +77,6 @@ export function playReveal({ scene, seed, settings, source }) {
let stage = "charge";
let card = null;
let cardDone = false;
let buyAnother = false;
// Narrow screens: the viewport sits below the main area; bring it into view (on phones
// at the top, the card comes up from the bottom).
@@ -327,7 +325,6 @@ export function playReveal({ scene, seed, settings, source }) {
);
}
document.dispatchEvent(new CustomEvent("mm:revealed"));
if (buyAnother) htmx.ajax("POST", "/act/buy-moon", { source: document.body, swap: "none" });
};
sfx.play("close");
layer.animate([{ opacity: 1 }, { opacity: 0 }], { duration: quick ? 150 : 420, easing: "ease-in", fill: "forwards" }).onfinish = done;
@@ -365,19 +362,9 @@ export function playReveal({ scene, seed, settings, source }) {
else close();
}
// "Buy another": once this reveal is gone (the next one replaces the card).
function buy(button) {
if (!cardDone || button.disabled) return false;
buyAnother = true;
close();
return true;
}
function onPointer(event) {
event.preventDefault();
if (event.target.closest?.("[data-reveal-close]") && cardDone) return close();
const more = event.target.closest?.("[data-reveal-buy]");
if (more && buy(more)) return;
advance();
}
function onKey(event) {
@@ -385,8 +372,6 @@ export function playReveal({ scene, seed, settings, source }) {
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape" && stage === "card") return close();
const more = document.activeElement?.closest?.("[data-reveal-buy]");
if (event.key !== "Escape" && more && buy(more)) return;
advance();
}
layer.addEventListener("pointerdown", onPointer);
+6 -94
View File
@@ -1,13 +1,11 @@
{{/* The advisors section (advisors.go): the combined bonus, the seats and who sits in them,
the hired advisors without a seat, and every advisor with a Hire button. A hired
advisor opens their panel (advisor-panel, in the side panel) to move, swap, unseat or
dismiss them. */}}
{{/* The advisors section (advisors.go): the combined bonus, the seats, and every advisor
with a Hire button. */}}
{{define "advisors-section"}}{{$p := .}}{{with .Advisors}}
{{define "advisors-section"}}{{with .Advisors}}
<div class="heading">
<div class="grow">
<h1>Advisors</h1>
<p class="muted measure">Each seated advisor boosts your whole operation. Click one to move, swap or dismiss them.</p>
<p class="muted measure">Each advisor takes one seat and stays for good.</p>
</div>
{{if .NextSeatPrice}}
<button class="btn btn-dashed" hx-post="/act/buy-seat"
@@ -22,36 +20,6 @@
<div class="mk-stat frame"><dt class="cap">Active bonuses</dt><dd>{{with .Bonuses}}{{template "bonus-chips" .}}{{else}}<span class="muted">None yet</span>{{end}}</dd></div>
</dl>
<ol class="adv-seats" aria-label="Advisor seats">
{{range .SeatList}}{{$n := .N}}
<li id="seat-{{.N}}" class="adv-seat{{if not .Advisor}} is-empty{{end}}">
{{with .Advisor}}
{{$u := $p.V.ToAdvisor .Name}}
<a href="{{$u}}" hx-get="{{$u}}" class="adv-seat-in" {{if eq $p.V.Adv .Name}}aria-current="true"{{end}}>
<img src="/static/assets/advisors/{{.Image}}" alt="" onerror="this.style.visibility='hidden'">
<span class="grow"><span class="cap num">Seat {{$n}}</span><br><b>{{.Name}}</b><br><span class="muted small num">{{.Effect}}</span></span>
</a>
{{else}}
<span class="adv-seat-in"><span class="grow"><span class="cap num">Seat {{.N}}</span><br><span class="muted">Empty</span></span></span>
{{end}}
</li>
{{end}}
</ol>
{{with .Bench}}
<h3 class="cap adv-bench-title">Hired, not seated <span class="muted">· no bonus</span></h3>
<div class="adv-bench">
{{range .}}
{{$u := $p.V.ToAdvisor .Name}}
<div class="line">
<a class="grow" href="{{$u}}" hx-get="{{$u}}" {{if eq $p.V.Adv .Name}}aria-current="true"{{end}}><b>{{.Name}}</b><br><span class="muted small num">{{.Effect}}</span></a>
<button class="btn btn-sm btn-primary" hx-post="/act/seat-advisor" hx-vals="{{vals "advisor" .Name "seat" (print $p.Advisors.FreeSeat)}}"
{{with $p.Advisors.SeatWhy}}disabled title="{{.}}"{{end}}>Seat</button>
</div>
{{end}}
</div>
{{end}}
<div class="cards">
{{range .Cards}}
<article class="card advisor-card {{if .Hired}}is-hired{{end}}">
@@ -62,69 +30,13 @@
<p class="mod-effect num">{{.Effect}}</p>
<p class="card-desc">{{.Description}}</p>
{{if .Hired}}
{{$u := $p.V.ToAdvisor .Name}}
<a class="btn card-buy" href="{{$u}}" hx-get="{{$u}}">{{if .Seat}}Seat {{.Seat}}{{else}}Not seated{{end}} · Manage</a>
<p class="ready card-buy">On your board</p>
{{else}}
<button class="btn btn-primary card-buy" hx-post="/act/hire" hx-vals="{{vals "advisor" .Name}}"
{{with $p.Advisors.HireWhy . $p.S.Balance}}disabled title="{{.}}"{{end}}>Hire · <span class="num">{{cur .Price}}</span></button>
{{with $.Advisors.HireWhy . $.S.Balance}}disabled title="{{.}}"{{end}}>Hire · <span class="num">{{cur .Price}}</span></button>
{{end}}
</div>
</article>
{{end}}
</div>
{{end}}{{end}}
{{/* A hired advisor's panel (View.Adv), in the side panel. Called with the Page. */}}
{{define "advisor-panel"}}{{$p := .}}{{with .Advisors.Panel}}
<h2>{{.Name}}</h2>
<p class="cap">{{.Title}}</p>
<div class="adv-portrait"><img src="/static/assets/advisors/{{.Image}}" alt="" onerror="this.style.visibility='hidden'"></div>
{{with .Effect}}<p class="mod-effect num">{{.}}</p>{{end}}
<p class="muted small">{{.Description}}</p>
<dl class="facts">
<div><dt>Seat</dt><dd class="num">{{if .Seat}}{{.Seat}}{{else}}Not seated{{end}}</dd></div>
<div><dt>Bonus</dt><dd>{{if .Seat}}Active{{else}}<span class="muted">Off until seated</span>{{end}}</dd></div>
</dl>
<p class="muted small">Changes count from your next drill, sale or batch.</p>
{{$name := .Name}}
{{with .Moves}}
<h3>{{if $p.Advisors.Panel.Seat}}Move to{{else}}Seat in{{end}}</h3>
{{range .}}
<div class="line">
<span class="cap num">{{.N}}</span>
{{with .Advisor}}
<span class="grow">{{.Name}}<br><span class="muted small num">{{.Effect}}</span></span>
<button class="btn btn-sm" hx-post="/act/swap-advisors" hx-vals="{{vals "advisor" $name "with" .Name}}">{{if $p.Advisors.Panel.Seat}}Swap{{else}}Replace{{end}}</button>
{{else}}
<span class="grow muted">Empty</span>
<button class="btn btn-sm btn-primary" hx-post="/act/seat-advisor" hx-vals="{{vals "advisor" $name "seat" (print .N)}}">{{if $p.Advisors.Panel.Seat}}Move{{else}}Seat{{end}}</button>
{{end}}
</div>
{{end}}
{{end}}
{{if .Seat}}
{{with .Swaps}}
<h3>Give the seat to</h3>
{{range .}}
<div class="line">
<span class="grow">{{.Name}}<br><span class="muted small num">{{.Effect}}</span></span>
<button class="btn btn-sm" hx-post="/act/swap-advisors" hx-vals="{{vals "advisor" $name "with" .Name}}">Swap in</button>
</div>
{{end}}
{{end}}
<h3>Unseat</h3>
<div class="line">
<span class="grow muted small">Frees seat {{.Seat}}. {{.Name}} stays hired, without a bonus, until seated again.</span>
<button class="btn btn-sm" hx-post="/act/unseat-advisor" hx-vals="{{vals "advisor" .Name}}">Unseat</button>
</div>
{{end}}
<h3>Dismiss</h3>
<div class="line">
<span class="grow muted small">Lets {{.Name}} go{{if .Seat}} and frees seat {{.Seat}}{{end}}. Nothing is refunded.</span>
<button class="btn btn-sm btn-danger" hx-post="/act/dismiss-advisor" hx-vals="{{vals "advisor" .Name}}"
hx-confirm="{{.Dismiss}}" data-confirm-title="Dismiss advisor" data-confirm-ok="Dismiss" data-confirm-danger>Dismiss</button>
</div>
{{end}}{{end}}
+6 -17
View File
@@ -28,7 +28,7 @@
data-now (the server's clock) lines the countdowns up with the server. */}}
{{define "view"}}
<div id="view" hidden {{template "oob-replace" .}} data-url="{{.V.URL}}" data-s="{{.V.Section}}"
data-moon="{{.V.Moon}}" data-list="{{if .V.Past}}past{{end}}" data-well="{{.V.Well}}" data-drill="{{.V.Drill}}" data-adv="{{.V.Adv}}" data-page="{{if and .V.Past (gt .V.Page 1)}}{{.V.Page}}{{end}}" data-now="{{.S.Now.UnixMilli}}"></div>
data-moon="{{.V.Moon}}" data-list="{{if .V.Past}}past{{end}}" data-well="{{.V.Well}}" data-drill="{{.V.Drill}}" data-now="{{.S.Now.UnixMilli}}"></div>
{{end}}
{{define "scene"}}
@@ -153,7 +153,7 @@
{{$active := .ActiveURL}}
<a href="{{$active}}" hx-get="{{$active}}" {{if not .PastView}}aria-current="page"{{end}}>Active <span class="muted num">{{len .Moons}}</span></a>
{{with .PastURL}}
<a href="{{.}}" hx-get="{{.}}" {{if $.PastView}}aria-current="page"{{end}} data-past-tab>Past <span class="muted num">{{$.S.PastTotal}}</span></a>
<a href="{{.}}" hx-get="{{.}}" {{if $.PastView}}aria-current="page"{{end}} data-past-tab>Past <span class="muted num">{{len $.Past}}</span></a>
{{else}}
<a aria-disabled="true" title="Moons you mine out go here" data-past-tab>Past <span class="muted num">0</span></a>
{{end}}
@@ -172,13 +172,6 @@
<span class="grow"><b>{{.Name}}</b>{{template "rating-chip" .Rating}}<br><span class="muted small num">Mined {{money .TotalMined}}{{with .FinishedOn}} · {{.}}{{end}}</span></span>
</a>
{{end}}
{{with .PastPaging}}{{if gt .Pages 1}}
<nav class="past-paging small" aria-label="Past moon pages">
{{with .Newer}}<a class="btn btn-sm" href="{{.}}" hx-get="{{.}}" rel="prev">‹ Newer</a>{{else}}<span class="btn btn-sm" aria-disabled="true">‹ Newer</span>{{end}}
<span class="muted num grow">{{.Page}} / {{.Pages}}</span>
{{with .Older}}<a class="btn btn-sm" href="{{.}}" hx-get="{{.}}" rel="next">Older ›</a>{{else}}<span class="btn btn-sm" aria-disabled="true">Older ›</span>{{end}}
</nav>
{{end}}{{end}}
{{else}}
{{range .Moons}}
{{$url := $.V.ToMoon .ID}}
@@ -342,9 +335,9 @@
{{$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>
<a class="card-media" href="{{$d}}" hx-get="{{$d}}" aria-label="{{.Item.Name}}: attachments"><img src="/static/assets/items/{{.Item.ImageAt .Tier}}" 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>
<div class="card-media"><img src="/static/assets/items/{{.Item.ImageAt .Tier}}" alt="" onerror="this.style.visibility='hidden'"></div>
{{end}}
<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>
@@ -592,9 +585,6 @@
{{end}}
</ul>
<div class="reveal-foot">
{{with .Next}}{{if ne .Why "locked"}}
<button type="button" class="btn" data-reveal-buy {{if not .CanBuy}}disabled title="{{.WhyText}}"{{end}}>Buy another · <span class="num">{{cur .Price}}</span></button>
{{end}}{{end}}
<button type="button" class="btn btn-primary" data-reveal-close>Start mining</button>
</div>
</section>
@@ -650,7 +640,6 @@
{{define "side"}}
<section id="side" class="side{{with .Well}}{{if le .Supply 0.0}} depleted{{end}}{{end}}" {{template "oob" .}}>
{{with .Drill}}{{template "drill-panel" .}}{{end}}
{{with .Advisors}}{{with .Panel}}{{template "advisor-panel" $}}{{end}}{{end}}
{{with .Well}}
{{$dry := le .Supply 0.0}}
<h2>{{template "dot" .Resource}}{{.Resource}} well</h2>
@@ -673,7 +662,7 @@
{{range .Idle}}
<button type="button" class="btn deploy-card{{if .Short}}{{if gt .Here 0.0}} is-short{{else}} is-spoken{{end}}{{end}}" hx-post="/act/stake" hx-vals="{{vals "tool" .ID "target" $.Well.ID}}" title="Deploy {{.Item.Name}} T{{.Tier}}{{range .Gear}} · {{.Name}}{{end}}">
<span class="deploy-card-media">
<img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'">
<img src="/static/assets/items/{{.Item.ImageAt .Tier}}" alt="" onerror="this.style.visibility='hidden'">
{{with .Gear}}<span class="deploy-card-pips pips">{{range .}}<i class="pip is-on"></i>{{end}}</span>{{end}}
{{if gt .Count 1}}<span class="deploy-card-count num">×{{.Count}}</span>{{end}}
</span>
@@ -704,7 +693,7 @@
<h3>Drills{{with .Queue}}{{if gt (len .) 1}} <span class="muted small">· {{len .}} share this well, oldest claims first</span>{{end}}{{end}}</h3>
{{range .Queue}}
<div class="line drill-line is-{{.State}}">
<span class="drill-thumb">{{if .Item}}<img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'">{{end}}</span>
<span class="drill-thumb">{{if .Item}}<img src="/static/assets/items/{{.Item.ImageAt .Tier}}" alt="" onerror="this.style.visibility='hidden'">{{end}}</span>
<span class="drill-main">
<span class="drill-name">{{if .Item}}{{.Item.Name}}{{else}}Tool{{end}} <span class="muted">T{{.Tier}}</span>{{template "gear" .Gear}}</span>
<span class="drill-meta small"><span class="muted"><span class="num">+{{money .Amount}}</span>/cycle{{with .ReachText}} · {{.}}{{end}}</span>