Compare commits

..
15 Commits
Author SHA1 Message Date
JosephFerano 25f315e8dc API: advisors bench (hired, not seated, in the web's order) and removed (hired but no longer in the config, no card) 2026-10-20 01:39:53 +07:00
JosephFerano 571075c5ff Rate limit: normalise client IPs (loopback as one, IPv4-mapped as IPv4) 2026-10-20 00:20:23 +07:00
JosephFerano fbde1b79ae API: view.aside, whether the web shows the aside 2026-10-20 00:19:42 +07:00
JosephFerano 4e7e2d010d API: well note, stake reach and market quote texts
Well rows get over, full, statusText and noteText (the web's well note);
stakes get reachText; market quotes get up/changeText and the pressure,
event boost and advisor texts the web shows (empty when not shown), so
clients don't word them.
2026-10-19 22:43:55 +07:00
JosephFerano 020d1f7197 Past moons: 10 a page
The Past tab loads the 10 most recently finished moons at a time
(loadPastPage: limit/offset in the query, plus a selected past moon from
another page); older pages via page= in the view URL (with list=past).
The web shows Newer/Older controls and the total on the tab; the API has
view.page, pastPage {page, pages, total, perPage} and takes page=.
2026-10-19 22:37:08 +07:00
JosephFerano 1fd1059aa7 New-moon reveal: Buy another
The reveal (web card and the API's reveal) carries the next moon's price
and whether it can be bought now (why: locked or credits). The web card
gets a Buy another button next to Start mining: it closes the reveal and
buys again (buy-moon), disabled when unaffordable, hidden while more
moons are locked.
2026-10-19 22:36:57 +07:00
JosephFerano a8bf63ffe4 Advisors: numbered seats, seat, unseat, swap and dismiss
Hired advisors sit in numbered seats (advisor_hire.seat, migration 016:
existing hires take seats in hiring order, once); only seated advisors
give their bonus. New actions seat-advisor, unseat-advisor, swap-advisors
and dismiss-advisor (nothing refunded), gated on advisors and logged.
The Advisors section shows the seats and the advisors without one; a
hired advisor opens a panel in the aside (adv= in the view URL). The API
advisors block carries seatList, freeSeat, each advisor's seat, seat
reasons and the dismiss dialog.
2026-10-19 22:36:11 +07:00
JosephFerano bc34f98e4c api.md: Texts section after the market chart 2026-10-19 18:09:22 +07:00
JosephFerano e6088994ef API: web texts beside reason codes, confirm dialogs, rating label/top, banner ids and end, skippable catalog
- Ratings everywhere carry label and top (as the reveal card).
- Banner items: kind, id, end, left; live events: id (matches the banner).
- Reason codes get the web's text (noSaleText, whyText, blockedText, reasonText, ...);
  confirm dialogs (sell moon/tool/attachment, buy seat/slot, new game) as {title, text, ok, danger}.
  The dynamic confirm and tooltip texts move from the templates into view-model methods
  (same output).
- Other labels: bonus chips, effect texts, reach wording (catalog.reach), well drill labels,
  drill fate lines, past moon dates.
- catalog=0 or catalog=<catalogVersion> leaves the catalog out.
2026-10-19 18:07:53 +07:00
JosephFerano 474542abc7 API: help note and clientSeq docs; test that section blocks come only with their section 2026-10-19 16:36:42 +07:00
JosephFerano c7073e53b9 JSON API: every section's state, reveal and buy modal, prediction rules, catalog, clientSeq
GET /api/state adds section blocks for s= (and include=): equipment with the
drill panel, store, refinery, advisors, leaderboard, help, the new-moon teaser
and the market chart; the moon sale offer in moon. Always: rules (pressure
curve, refinery timing, bonuses, live boosts, supply scale), catalog (image
URLs, tool codes), refinery jobs, advisor bonuses, live events, showNav.
Act responses carry the reveal card after buy-moon, the deploy suggestions
after buy-tool, and echo clientSeq. Reasons are codes. Mapped from the
existing view models; HTML unchanged.
2026-10-19 16:34:58 +07:00
JosephFerano 66705d9aba api.md: document GET /api/moon-settings for the Defold client 2026-10-19 14:43:01 +07:00
JosephFerano 0d2a790506 API new-game: one Set-Cookie, for the new player 2026-10-19 13:39:54 +07:00
JosephFerano 960a03a4f6 JSON API for game clients: GET /api/state, POST /api/act/<action>
The action switch moves from handleAction into runAction, shared by the htmx
handler and the API. The state maps the view models to JSON (unix ms times).
Player id from X-MM-User or the cookie, else a new player. api.md documents it;
api_test.go builds it for every test view and runs in make check.
2026-10-19 13:38:23 +07:00
JosephFerano d8a6ae1419 Workspace layout: repo in server/, worktrees beside it; pin compose project name 2026-10-19 13:26:28 +07:00
28 changed files with 3891 additions and 171 deletions

No files matched your search

+24 -7
View File
@@ -25,19 +25,34 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
- Postgres 16, stock image. No functions or extensions in the database. - Postgres 16, stock image. No functions or extensions in the database.
## Layout ## Layout
- The repo is `server/` in the `MoonMiners/` workspace; agent worktrees go next to it
(`git worktree add ../wt-<task> -b <task>`), a Defold client later in `../client`.
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env` - `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis). loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
- `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it - `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a 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 `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 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. 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).
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as - `views.go`: the `View` (section, moon, well, tab; carried in the URL as
`/?s=&moon=&well=`), the `Page` view model, formatting helpers. `/?s=&moon=&well=`), the `Page` view model, formatting helpers.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body - `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an (htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
action and responds the same way. action and responds the same way.
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422).
Player id from `X-MM-User` or the cookie, else a new player (id echoed back).
`api_sections.go`: every section's block (equipment and drill panel, store, refinery,
advisors, leaderboard, help, new-moon teaser, reveal and buy modal in act responses, moon
sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or
`include=`; reasons are codes with the web's text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
for every test view.
- `config/game.json`: the game config (starting credits/resources/tools, moon price, well - `config/game.json`: the game config (starting credits/resources/tools, moon price, well
supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a
name, a placeholder description and resource `affinities`, refinery), embedded. name, a placeholder description and resource `affinities`, refinery), embedded.
@@ -83,15 +98,17 @@ 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 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` (`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped). (`inventory_item_id` null: unequipped).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire and buy - `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat rules, Advisors section. Tables `advisor_hire`, `users.advisor_seats`. 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`.
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding` - `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`, holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
`all` for players from before it). New players see only the first moon purchase; deploying `all` for players from before it). New players see only the first moon purchase; deploying
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades, reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
attachments, moon sale, Redrill), claiming both raw inputs of a recipe Refinery, buying a attachments, moon sale, Redrill), claiming both raw inputs of a recipe Refinery, buying a
tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
locked sections to the moon view and `handleAction` refuses locked actions (`actionGates`). locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
sections and actions need a gate there. sections and actions need a gate there.
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players` - `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
@@ -127,7 +144,7 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`). via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`. - `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–015): - `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–016):
changes for existing ones, applied on every `migrate`, so each must be idempotent. changes for existing ones, applied on every `migrate`, so each must be idempotent.
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format. - `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
@@ -163,8 +180,8 @@ commit `.env`. `ADMIN_USER_IDS` (comma-separated player ids) grants access to `/
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
the address bar in sync. the address bar in sync.
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section` - New section: add it to `sections` and `State.page` (views.go) and a `<name>-section`
template in `app.html`. New action: a case in `server.handleAction` plus a button with template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
`hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically, `/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
so action parameters must use other names. Give it a sound in fx.js if it needs one. so action parameters must use other names. Give it a sound in fx.js if it needs one.
- Clicks give instant feedback only (busy state + spinner). No client-side game logic or - Clicks give instant feedback only (busy state + spinner). No client-side game logic or
prediction; the server is the authority. Pages don't reload on timers: countdown labels prediction; the server is the authority. Pages don't reload on timers: countdown labels
+1 -1
View File
@@ -23,7 +23,7 @@ load-config:
check: check:
test -z "$$(gofmt -l .)" && go vet ./... test -z "$$(gofmt -l .)" && go vet ./...
go test -run '^TestTemplates$$' -count=1 . go test -run '^(TestTemplates|TestAPI)$$' -count=1 .
for f in web/static/js/*.js; do node --check $$f || exit 1; done for f in web/static/js/*.js; do node --check $$f || exit 1; done
node --no-warnings scripts/check-moon-traits.mjs node --no-warnings scripts/check-moon-traits.mjs
+298 -27
View File
@@ -1,12 +1,19 @@
package main package main
// Advisors: people a player hires once, for credits, who boost the whole operation (drill // Advisors: people a player hires once, for credits, who boost the whole operation (drill
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments). Each // speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments).
// advisor takes a seat: players start with one and buy more (advisors.seatPrices), like // Seats are numbered: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Tables advisor_hire and users.advisor_seats. // 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.
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -63,39 +70,199 @@ func lockAdvisors(ctx context.Context, tx pgx.Tx, userID string) (int, error) {
return seats, err return seats, err
} }
// hireAdvisor hires an advisor into a free seat. // 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.
func hireAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error { func hireAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
a := cfg().advisor(name) a := cfg().advisor(name)
if a == nil { if a == nil {
return gameErr("Unknown advisor") return gameErr("Unknown advisor")
} }
return inTx(ctx, db, func(tx pgx.Tx) error { return inTx(ctx, db, func(tx pgx.Tx) error {
seats, err := lockAdvisors(ctx, tx, userID) seats, hires, err := lockHires(ctx, tx, userID)
if err != nil { if err != nil {
return err return err
} }
var hired int if findHire(hires, a.Name) != nil {
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) return gameErr("%s already works for you", a.Name)
} }
if hired >= seats { seat := freeSeat(seats, hires)
if seat == 0 {
return gameErr("All advisor seats are taken. Buy another seat first") return gameErr("All advisor seats are taken. Buy another seat first")
} }
if err := debit(ctx, tx, userID, a.Price); err != nil { if err := debit(ctx, tx, userID, a.Price); err != nil {
return err return err
} }
_, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor) values ($1, $2)", userID, a.Name) _, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", userID, a.Name, seat)
if err != nil { if err != nil {
return err return err
} }
return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price}) 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})
}) })
} }
@@ -129,39 +296,143 @@ func buyAdvisorSeat(ctx context.Context, db *pgxpool.Pool, userID string) (int,
type AdvisorsPage struct { type AdvisorsPage struct {
Cards []AdvisorCard Cards []AdvisorCard
Seats, Hired int 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
MaxSeats int MaxSeats int
NextSeat int // number of the next seat to buy NextSeat int // number of the next seat to buy
NextSeatPrice float64 // 0 at the cap NextSeatPrice float64 // 0 at the cap
CanBuySeat bool CanBuySeat bool
Bonuses []BonusRow // the combined advisor bonus, capped 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 { type AdvisorCard struct {
*Advisor *Advisor
Hired bool Hired bool
Seat int // 0: not seated (or not hired)
CanAfford bool CanAfford bool
} }
func (s *State) advisorsPage() *AdvisorsPage { // 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 {
c := cfg() c := cfg()
p := &AdvisorsPage{Seats: s.AdvisorSeats, Hired: len(s.Advisors), MaxSeats: 1 + len(c.Advisors.SeatPrices), p := &AdvisorsPage{Seats: s.AdvisorSeats, MaxSeats: 1 + len(c.Advisors.SeatPrices),
NextSeat: s.AdvisorSeats + 1, NextSeat: s.AdvisorSeats + 1,
Bonuses: s.BonusRows()} Bonuses: s.BonusRows()}
if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) { if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) {
p.NextSeatPrice = c.Advisors.SeatPrices[n] p.NextSeatPrice = c.Advisors.SeatPrices[n]
p.CanBuySeat = s.Balance >= p.NextSeatPrice p.CanBuySeat = s.Balance >= p.NextSeatPrice
} }
for i := range c.Advisors.Items { card := func(a *Advisor) AdvisorCard {
a := &c.Advisors.Items[i]
card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price} card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price}
for _, h := range s.Advisors { for _, h := range s.Hires {
card.Hired = card.Hired || h == a.Name if h.Name == a.Name {
card.Hired, card.Seat = true, h.Seat
}
} }
p.Cards = append(p.Cards, card) return card
}
for i := range c.Advisors.Items {
p.Cards = append(p.Cards, card(&c.Advisors.Items[i]))
}
// 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
} }
return p 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. // SeatFree reports whether the player has an empty advisor seat.
func (p *AdvisorsPage) SeatFree() bool { return p.Hired < p.Seats } func (p *AdvisorsPage) SeatFree() bool { return p.FreeSeat > 0 }
// SeatConfirm is the question before buying the next seat.
func (p *AdvisorsPage) SeatConfirm() string {
return fmt.Sprintf("Add advisor seat %d for %s?", p.NextSeat, formatCredits(p.NextSeatPrice))
}
// HireWhy is why an advisor can't be hired (the Hire button's tooltip), "" if they can.
func (p *AdvisorsPage) HireWhy(c AdvisorCard, balance float64) string {
switch {
case !p.SeatFree() && p.NextSeatPrice > 0:
return "All seats taken. Buy another seat first"
case !p.SeatFree():
return "All seats taken"
case !c.CanAfford:
return shortBy(c.Price - balance)
}
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"
}
+867
View File
@@ -0,0 +1,867 @@
package main
// JSON API for game clients other than the htmx page (a Defold client): the same state the
// views build and the same actions, run through runAction (server.go). Times are unix
// milliseconds (null when unset), durations seconds; the client formats. See api.md.
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"mime"
"net/http"
"strconv"
"strings"
"time"
)
// userHeader carries the player id for clients without cookies; responses echo it.
const userHeader = "X-MM-User"
// apiFeatures are the onboarding features the state reports (onboarding.go).
var apiFeatures = []string{"moons", "equipment", "market", "store", "refinery", "advisors",
"leaderboard", "help", "new-moon", "claim-all", "redrill", "redeploy"}
// ------------------------------------------------------------------ state
type APIState struct {
UserID string `json:"userId"`
Now int64 `json:"now"`
View APIView `json:"view"` // the resolved view (actions may move it)
Credits float64 `json:"credits"`
Resources []APIAmount `json:"resources"`
Refined []APIAmount `json:"refined"`
MoonPrice float64 `json:"moonPrice"`
Unlocked map[string]bool `json:"unlocked"`
Shine []string `json:"shine"` // revealed tabs not opened yet
Ready int `json:"ready"` // finished drills, all moons
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"`
Tools []APITool `json:"tools"`
Stakes []APIStake `json:"stakes"` // every running or unclaimed drill
Moon *APIMoon `json:"moon"` // s=moons with a moon
Well *APIWell `json:"well"` // s=moons with a well selected
Market *APIMarket `json:"market"` // once the market is unlocked
Banner []APIBanner `json:"banner"`
// Always present (api_sections.go).
ShowNav bool `json:"showNav"` // the section tabs show (not while Moons would be the only one)
Jobs []APIJob `json:"jobs"` // refinery batches, once the refinery is open
Bonuses []APIBonusRow `json:"bonuses"` // active advisor bonuses (the bonus chips)
LiveEvents []APILiveEvent `json:"liveEvents"`
Rules *APIRules `json:"rules"`
Catalog *APICatalog `json:"catalog,omitempty"` // left out with catalog=0 or catalog=<catalogVersion>
// A hash of the catalog: send it back as catalog= to skip the catalog while it's unchanged.
CatalogVersion string `json:"catalogVersion"`
// Section blocks: present for the view's section (s=) and the sections in include=.
Equipment *APIEquipment `json:"equipment,omitempty"`
Store *APIStore `json:"store,omitempty"`
Refinery *APIRefinery `json:"refinery,omitempty"`
Advisors *APIAdvisors `json:"advisors,omitempty"`
Leaderboard *APILeaderboard `json:"leaderboard,omitempty"`
Help *APIHelp `json:"help,omitempty"`
NewMoon *APINewMoon `json:"newMoon,omitempty"`
}
type APIView struct {
Section string `json:"section"`
Moon string `json:"moon"`
Well string `json:"well"`
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 {
Name string `json:"name"`
Amount float64 `json:"amount"`
}
// APIRating is a moon's rating as the web shows it: the tier and its label, the stars,
// the percentile and "top N%" (the share of moons at least this good).
type APIRating struct {
Tier string `json:"tier"`
Label string `json:"label"`
Stars int `json:"stars"`
Pct int `json:"pct"`
Top int `json:"top"`
}
type APIWellSupply struct {
ID string `json:"id"`
Resource string `json:"resource"`
Supply float64 `json:"supply"`
}
type APIMoonListItem struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Rating *APIRating `json:"rating"`
Wells []APIWellSupply `json:"wells"`
Drills int `json:"drills"`
Ready int `json:"ready"`
Over bool `json:"over"` // a well has more drills than supply
Selected bool `json:"selected"`
CreatedAt int64 `json:"createdAt"`
Finished bool `json:"finished"`
FinishedAt *int64 `json:"finishedAt"`
Mined []APIAmount `json:"mined,omitempty"` // past moons: claimed from it
FinishedOn string `json:"finishedOn,omitempty"` // past moons: the web's date, e.g. "Oct 18"
}
type APITool struct {
ID string `json:"id"`
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // model code, e.g. "PD-1"
Image string `json:"image"` // static URL
Tier int `json:"tier"`
MaxTier int `json:"maxTier"`
Reach int `json:"reach"` // wells one cycle mines
CycleSecs int `json:"cycleSecs"` // a stake now, with bonuses and live events
Yield float64 `json:"yield"`
Attachments []string `json:"attachments"`
StakeID string `json:"stakeId,omitempty"` // mining, if set
}
type APIStake struct {
ID string `json:"id"`
MoonID string `json:"moonId"`
WellID string `json:"wellId"` // the first well it mines
ToolID string `json:"toolId"`
Resource string `json:"resource"`
Amount float64 `json:"amount"`
Reach int `json:"reach"`
Start int64 `json:"start"`
End int64 `json:"end"`
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).
Gets *float64 `json:"gets,omitempty"`
Here *float64 `json:"here,omitempty"`
State string `json:"state,omitempty"`
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 T0: +2"
}
type APIDrillGroup struct {
Drills []APIStake `json:"drills"`
Ready int `json:"ready"`
ReadyAmount float64 `json:"readyAmount"` // what claiming the ready ones gets
NextEnd *int64 `json:"nextEnd"`
Cap float64 `json:"cap"`
Label string `json:"label"` // the web's compact label of its drills, e.g. "PD-1 ×3 +1"
}
type APIMoon struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Description string `json:"description"`
Rating *APIRating `json:"rating"`
Finished bool `json:"finished"`
FinishedOn string `json:"finishedOn,omitempty"` // past moon: the web's date, e.g. "Oct 18"
Ready int `json:"ready"`
NextEnd *int64 `json:"nextEnd"`
Wells []APIWellRow `json:"wells"`
Wide *APIDrillGroup `json:"wide"` // drills that mine several wells (claim-wide)
Sale *APIMoonSale `json:"sale"` // the resale offer, once unlocked.store (null for a past moon)
}
type APIWellRow struct {
APIWellSupply
APIDrillGroup // drills that mine only this well (claim-well)
Wide []string `json:"wide"` // ids of the moon's multi-well drills that mine it too
Selected bool `json:"selected"`
Redrill int `json:"redrill"` // idle tools that last mined it
Demand float64 `json:"demand"`
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 {
ID string `json:"id"`
Resource string `json:"resource"`
Rate float64 `json:"rate"` // credits per unit now
Idle []APIToolGroup `json:"idle"` // tools to deploy here
Better *APIBetterWell `json:"better"`
}
type APIToolGroup struct {
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Tier int `json:"tier"`
Count int `json:"count"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
Free float64 `json:"free"`
Short bool `json:"short"`
Attachments []string `json:"attachments"`
}
type APIBetterWell struct {
MoonID string `json:"moonId"`
WellID string `json:"wellId"`
Resource string `json:"resource"`
Free float64 `json:"free"`
ToolID string `json:"toolId"`
}
type APIMarket struct {
Quotes []APIQuote `json:"quotes"`
Selected string `json:"selected"`
Total float64 `json:"total"` // value of everything held
Held int `json:"held"` // kinds held
Best string `json:"best"` // biggest 24h gain
Worst string `json:"worst"` // biggest 24h loss
Chart *APIChart `json:"chart"` // s=market (or include=market): the selected quote's 24h history
}
type APIQuote struct {
Item string `json:"item"`
Refined bool `json:"refined"`
Balance float64 `json:"balance"`
Price float64 `json:"price"` // one unit sells for now: rate × boost × advisor × factor
Rate float64 `json:"rate"`
Boost float64 `json:"boost"`
Advisor float64 `json:"advisor"`
Pressure float64 `json:"pressure"`
Factor float64 `json:"factor"`
Open float64 `json:"open"` // market rate 24h ago
Change float64 `json:"change"` // percent over 24h
High float64 `json:"high"`
Low float64 `json:"low"`
Value float64 `json:"value"`
// For the sell ticket: the price before selling pressure, the base price (pressure is
// counted in base-price credits) and what selling everything held pays now.
Symbol string `json:"symbol"`
Unpressed float64 `json:"unpressed"`
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).
type APIBanner struct {
Level string `json:"level"`
Text string `json:"text"`
Left string `json:"left"` // the web's "3h 20m left" (events only, else "")
Kind string `json:"kind"` // announcement, event
ID int `json:"id"`
End int64 `json:"end"`
}
func unixMS(t time.Time) *int64 {
if t.IsZero() {
return nil
}
ms := t.UnixMilli()
return &ms
}
func apiRating(r *Rating) *APIRating {
if r == nil {
return nil
}
return &APIRating{Tier: r.Tier, Label: r.Label, Stars: r.Stars, Pct: r.Pct, Top: r.Top()}
}
func apiAmounts(rows []ResourceBalance) []APIAmount {
out := []APIAmount{}
for _, r := range rows {
out = append(out, APIAmount{Name: r.Resource, Amount: r.Balance})
}
return out
}
func apiGear(gear []*Attachment) []string {
out := []string{}
for _, a := range gear {
out = append(out, a.Name)
}
return out
}
func apiStake(st StakeRow, projected bool) APIStake {
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()}
if projected {
gets, here := st.Gets, st.Here
a.Gets, a.Here, a.State, a.Fate = &gets, &here, st.State, st.Fate()
}
return a
}
func apiGroup(g DrillGroup) APIDrillGroup {
a := APIDrillGroup{Drills: []APIStake{}, Ready: g.Ready, ReadyAmount: g.ReadyAmount, NextEnd: unixMS(g.NextEnd), Cap: g.Cap,
Label: g.DrillLabel()}
for _, d := range g.Drills {
a.Drills = append(a.Drills, apiStake(d, true))
}
return a
}
func apiMoonList(rows []MoonListRow) []APIMoonListItem {
out := []APIMoonListItem{}
for _, r := range rows {
m := APIMoonListItem{ID: r.ID, Name: r.Name, Kind: r.Kind, Rating: apiRating(r.Rating), Wells: []APIWellSupply{},
Drills: r.Drills, Ready: r.Ready, Over: r.Over, Selected: r.Selected, CreatedAt: r.CreatedAt.UnixMilli(),
Finished: r.Finished, FinishedAt: unixMS(r.FinishedAt)}
for _, w := range r.Wells {
m.Wells = append(m.Wells, APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply})
}
if r.Finished {
m.Mined, m.FinishedOn = apiAmounts(r.Mined), r.FinishedOn()
}
out = append(out, m)
}
return out
}
// apiState is the JSON state for a page the views built (State.page). extra are pages of
// other sections (include=) whose blocks it adds.
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},
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{},
Banner: []APIBanner{}}
for _, r := range s.Refined {
a.Refined = append(a.Refined, APIAmount{Name: r.Item, Amount: r.Balance})
}
for _, f := range apiFeatures {
switch f {
case "redrill":
a.Unlocked[f] = s.Unlocked("market")
case "redeploy":
a.Unlocked[f] = s.Unlocked("store")
default:
a.Unlocked[f] = s.Unlocked(f)
}
}
for _, tab := range onboardingTabs {
if s.Shine(tab) {
a.Shine = append(a.Shine, tab)
}
}
for _, t := range s.Tools {
item := s.StoreItem(t.StoreItemID)
if item == nil {
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),
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 {
tool.StakeID = st.ID
}
a.Tools = append(a.Tools, tool)
}
for _, st := range s.Stakes {
a.Stakes = append(a.Stakes, apiStake(s.stakeRow(st), false))
}
if d := p.Moon; d != nil {
m := &APIMoon{ID: d.ID, Name: d.Name, Kind: d.Kind, Description: d.Description, Rating: apiRating(d.Rating),
Finished: d.Finished, FinishedOn: d.FinishedOn(), Ready: d.Ready, NextEnd: unixMS(d.NextEnd), Wells: []APIWellRow{}}
if d.Wide != nil {
g := apiGroup(*d.Wide)
m.Wide = &g
}
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()}
for _, st := range w.Wide {
row.Wide = append(row.Wide, st.ID)
}
m.Wells = append(m.Wells, row)
}
if s.Unlocked("store") {
m.Sale = apiMoonSale(d.Sale)
}
a.Moon = m
}
if w := p.Well; w != nil {
well := &APIWell{ID: w.ID, Resource: w.Resource, Rate: w.Rate, Idle: []APIToolGroup{}}
for _, g := range w.Idle {
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
Tier: g.Tier, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield, Reach: max(1, g.Reach), Here: g.Here,
Free: g.Free, Short: g.Short(), Attachments: apiGear(g.Gear)})
}
if b := w.Better; b != nil {
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
if b.Tool != nil {
well.Better.ToolID = b.Tool.ID
}
}
a.Well = well
}
// The market is part of the core loop, so it comes with every state once unlocked.
if trade := p.Trade; trade != nil || s.Unlocked("market") {
if trade == nil {
var rows []MarketRow
for _, r := range s.Resources {
rows = append(rows, MarketRow{ResourceBalance: r})
}
if s.Unlocked("refinery") {
rows = append(rows, s.refinedMarketRows()...)
}
trade = s.marketPage(rows, v)
}
a.Market = apiMarket(trade, false)
}
for _, b := range p.Banner {
a.Banner = append(a.Banner, APIBanner{Level: b.Level, Text: b.Text, Left: b.Left, Kind: b.Kind, ID: b.ID,
End: b.EndsAt.UnixMilli()})
}
a.ShowNav, a.Jobs, a.Bonuses = s.ShowNav(), []APIJob{}, apiBonusRows(s.Bonus())
a.LiveEvents, a.Rules, a.Catalog = []APILiveEvent{}, apiRules(p), apiCatalog(s)
a.CatalogVersion = a.Catalog.version()
if s.Unlocked("refinery") {
for _, j := range s.Jobs {
a.Jobs = append(a.Jobs, apiJob(j, a.Now))
}
}
for _, page := range append([]*Page{p}, extra...) {
a.addSection(page)
}
return a
}
// addSection adds the block of a page's section, unless an earlier page added it.
func (a *APIState) addSection(p *Page) {
switch p.V.Section {
case "equipment":
if a.Equipment == nil {
a.Equipment = apiEquipment(p)
}
case "store":
if a.Store == nil {
a.Store = apiStore(p)
}
case "refinery":
if a.Refinery == nil && p.Refinery != nil {
a.Refinery = apiRefinery(p)
}
case "advisors":
if a.Advisors == nil && p.Advisors != nil {
a.Advisors = apiAdvisors(p)
}
case "leaderboard":
if a.Leaderboard == nil && p.Ranking != nil {
a.Leaderboard = apiLeaderboard(p.Ranking)
}
case "help":
if a.Help == nil {
a.Help = apiHelp(p.S)
}
case "new-moon":
if a.NewMoon == nil && p.NewMoon != nil {
a.NewMoon = apiNewMoon(p.NewMoon)
}
case "market":
if p.Trade != nil && (a.Market == nil || a.Market.Chart == nil) {
a.Market = apiMarket(p.Trade, true)
}
}
}
// apiMarket maps the market; full adds the chart and the sparklines.
func apiMarket(trade *MarketPage, full bool) *APIMarket {
c := cfg()
mk := &APIMarket{Quotes: []APIQuote{}, Total: trade.Total, Held: trade.Held}
if trade.Selected != nil {
mk.Selected = trade.Selected.Resource
}
if trade.Best != nil {
mk.Best = trade.Best.Resource
}
if trade.Worst != nil {
mk.Worst = trade.Worst.Resource
}
for _, q := range trade.Quotes {
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()
}
// 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 {
aq.Spark = spark(q.series)
}
mk.Quotes = append(mk.Quotes, aq)
}
if full && trade.Selected != nil {
mk.Chart = &APIChart{Item: trade.Selected.Resource, Start: trade.Chart.Start, Step: trade.Chart.Step,
Prices: append([]float64{}, trade.Selected.series...)}
}
return mk
}
// ------------------------------------------------------------------ handlers
type APIResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
UserID string `json:"userId,omitempty"`
New bool `json:"newPlayer,omitempty"` // the request had no known player: one was created
Toast *APIToast `json:"toast,omitempty"`
FX *APIFX `json:"fx,omitempty"`
// One-shot results of an action, as the web shows them: the reveal card after buy-moon,
// the deploy suggestions after buy-tool.
Reveal *APIReveal `json:"reveal,omitempty"`
BuyModal *APIBuyModal `json:"buyModal,omitempty"`
ClientSeq any `json:"clientSeq,omitempty"` // the request's clientSeq, echoed (a number if it is an integer)
State *APIState `json:"state,omitempty"`
}
type APIToast struct {
Message string `json:"message"`
Error bool `json:"error"`
Kind string `json:"kind"`
}
// APIFX is what the web client plays effects from (fx.js): the action, the moons it mined
// out and what a sale earned.
type APIFX struct {
Kind string `json:"kind"`
Finished []string `json:"finished"`
Credits float64 `json:"credits"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("api: %v", err)
}
}
func apiError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, APIResponse{Error: message})
}
func apiServerError(w http.ResponseWriter, err error) {
log.Printf("error: %v", err)
apiError(w, http.StatusInternalServerError, "Something went wrong")
}
// apiUser finds the player from the X-MM-User header or the mm_user cookie, or creates one
// (as POST /session does). It echoes the id in the header and cookie; on a refusal it
// writes the error response and returns "".
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
ctx := r.Context()
userID = r.Header.Get(userHeader)
if userID == "" {
if c, err := r.Cookie(userCookie); err == nil {
userID = c.Value
}
}
if !uuidPattern.MatchString(userID) || !userExists(ctx, s.db, userID) {
if !s.newPlayers.allow(r) {
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
return "", false
}
var err error
if userID, err = createUser(ctx, s.db, newPlayerName()); err != nil {
apiServerError(w, err)
return "", false
}
created = true
} else if isBanned(ctx, s.db, userID) {
apiError(w, http.StatusForbidden, "This account is suspended")
return "", false
}
w.Header().Set(userHeader, userID)
setUserCookie(w, userID)
return userID, created
}
// 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)
if err != nil {
return nil, err
}
page, err := s.apiPageFor(ctx, state, v)
if err != nil {
return nil, err
}
return s.apiStateWith(ctx, page, get)
}
// apiPageFor builds the page for a resolved view, with what the server loads for it.
func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, error) {
page := state.page(v)
if err := s.loadRanking(ctx, page); err != nil {
return nil, err
}
return page, nil
}
// apiStateWith maps a page and the include= sections, the banner and live events. include
// lists other sections (comma-separated, or "all") whose blocks to add; catalog=0 leaves the
// catalog out, catalog=<catalogVersion> leaves it out while it's unchanged.
func (s *server) apiStateWith(ctx context.Context, page *Page, get func(string) string) (*APIState, error) {
page.Banner = s.banner(ctx)
var extra []*Page
for _, section := range apiIncludes(get("include")) {
if section == page.V.Section {
continue
}
v := page.V
v.Section = section
if v = v.resolve(page.S); v.Section != section {
continue // locked
}
p, err := s.apiPageFor(ctx, page.S, v)
if err != nil {
return nil, err
}
extra = append(extra, p)
}
a := apiState(page, extra...)
a.skipCatalog(get("catalog"))
events, err := listLiveEvents(ctx, s.db)
if err != nil {
return nil, err
}
for _, e := range events {
if e.Active(page.S.Now) {
a.LiveEvents = append(a.LiveEvents, APILiveEvent{ID: e.ID, Name: e.Name, Kind: e.Kind, Resource: e.Resource,
Multiplier: e.Multiplier, Start: e.StartsAt.UnixMilli(), End: e.EndsAt.UnixMilli(), Headline: e.Headline()})
}
}
return a, nil
}
// skipCatalog leaves the catalog out for catalog=0, or catalog=<version> while it's that one.
func (a *APIState) skipCatalog(param string) {
if param != "" && (param == "0" || param == a.CatalogVersion) {
a.Catalog = nil
}
}
// apiIncludes reads include=: section names, comma-separated, or "all".
func apiIncludes(include string) []string {
if include == "all" {
return []string{"equipment", "market", "store", "refinery", "advisors", "leaderboard", "help", "new-moon"}
}
var out []string
for _, name := range strings.Split(include, ",") {
if name = strings.TrimSpace(name); sections[name] && name != "moons" {
out = append(out, name)
}
}
return out
}
// handleAPIState is GET /api/state: the state for a view (s, moon, well, list, pick, drill).
func (s *server) handleAPIState(w http.ResponseWriter, r *http.Request) {
userID, created := s.apiUser(w, r)
if userID == "" {
return
}
ctx := r.Context()
// Opening a newly revealed tab stops its shine, as on the page.
if err := markSeen(ctx, s.db, userID, r.FormValue("s")); err != nil {
apiServerError(w, err)
return
}
state, err := s.apiPage(ctx, userID, viewFromForm(r.FormValue), r.FormValue)
if err != nil {
apiServerError(w, err)
return
}
s.logSession(ctx, userID)
writeJSON(w, http.StatusOK, APIResponse{OK: true, UserID: userID, New: created, State: state})
}
// handleAPIAction is POST /api/act/{action}: runs an action like POST /act/{action} and
// answers with the toast and the state after it. A rule violation is a 422 (with the state).
func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
get, err := apiParams(r)
if err != nil {
apiError(w, http.StatusBadRequest, "Bad request")
return
}
userID, created := s.apiUser(w, r)
if userID == "" {
return
}
ctx := r.Context()
res, err := s.runAction(ctx, userID, r.PathValue("action"), get)
if errors.Is(err, errUnknownAction) {
apiError(w, http.StatusNotFound, "Unknown action")
return
}
if err != nil {
apiServerError(w, err)
return
}
if res.Action == "new-game" {
// A fresh player; the client stores the new id.
if !s.newPlayers.allow(r) {
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
return
}
if userID, err = createUser(ctx, s.db, newPlayerName()); err != nil {
apiServerError(w, err)
return
}
w.Header().Set(userHeader, userID)
w.Header().Del("Set-Cookie") // the old player's
setUserCookie(w, userID)
created, res.View, res.Message = true, View{}, "New game started"
}
state, v, err := loadView(ctx, s.db, userID, res.View)
if err != nil {
apiServerError(w, err)
return
}
toast := res.toast(state)
page, err := s.apiPageFor(ctx, state, v)
if err != nil {
apiServerError(w, err)
return
}
resp := APIResponse{OK: !toast.Error, UserID: userID, New: created, ClientSeq: apiSeq(get("clientSeq")),
Toast: &APIToast{Message: toast.Message, Error: toast.Error, Kind: toast.Kind}}
if !toast.Error {
if res.BoughtTool != "" {
resp.BuyModal = apiBuyModal(state.buySuggestions(res.BoughtTool))
}
if res.Action == "buy-moon" {
resp.Reveal = apiReveal(page.moonReveal())
}
}
if resp.State, err = s.apiStateWith(ctx, page, get); err != nil {
apiServerError(w, err)
return
}
status := http.StatusOK
if toast.Error {
resp.Error, status = toast.Message, http.StatusUnprocessableEntity
} else {
resp.FX = &APIFX{Kind: toast.Kind, Finished: res.Finished, Credits: toast.Credits}
if resp.FX.Finished == nil {
resp.FX.Finished = []string{}
}
}
writeJSON(w, status, resp)
}
// apiParams reads an action's parameters from the query and form (parsed by limitBody) or
// a JSON object body; JSON numbers and booleans become strings (true is "1").
func apiParams(r *http.Request) (func(string) string, error) {
ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if ct != "application/json" {
return r.FormValue, nil
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
vals := map[string]string{}
for k, v := range body {
switch v := v.(type) {
case string:
vals[k] = v
case float64:
vals[k] = strconv.FormatFloat(v, 'f', -1, 64)
case bool:
if v {
vals[k] = "1"
}
case nil:
default:
return nil, errors.New("api: parameters must be strings, numbers or booleans")
}
}
return func(k string) string {
if v, ok := vals[k]; ok {
return v
}
return r.FormValue(k)
}, nil
}
// apiSeq is a client's clientSeq as it echoes back: an integer, else the string, else nil.
func apiSeq(seq string) any {
if seq == "" {
return nil
}
if n, err := strconv.ParseInt(seq, 10, 64); err == nil {
return n
}
return seq
}
+495
View File
@@ -0,0 +1,495 @@
# Moon Miners JSON API
For game clients other than the htmx page (the Defold client). Same state and rules as the
web game: `GET /api/state` is built from the same view models as `GET /`, and
`POST /api/act/<action>` runs the same code as `POST /act/<action>` (`runAction` in
server.go), onboarding gates included. Code: `api.go`.
Conventions:
- Times are unix milliseconds (`null` when unset), durations seconds. `state.now` is the
server's clock: compute time left as `end - now` (plus the time since the response).
- Amounts and prices are raw numbers; the client formats them (currency symbol ₥, see
[Texts](#texts) for the web's formats).
- Reasons are codes, each with the web's player-facing text next to it (`why` and
`whyText`, `noSale` and `noSaleText`, …), and actions the web confirms carry the dialog's
words (`confirm`, see [Texts](#texts)). Other display text: names, descriptions, toast
messages, banner text and the few labels listed with their fields.
## Auth
The player id is read from the `X-MM-User` header, else the `mm_user` cookie. If neither
names an existing player, a new one is created (at most 10 per minute per IP, else
`429`), with `newPlayer: true` in the body. Every successful response returns the id in
the `X-MM-User` header, the `mm_user` cookie and `userId`; store it and send it back.
Banned players get `403`.
## GET /api/state
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).
`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=`
clears its shine.
`catalog`: `0` leaves `state.catalog` out; the `catalogVersion` of the catalog the client
has leaves it out while it's unchanged (it changes when admins change the config). Without
it the catalog comes as before. `state.catalogVersion` is always there.
Response `200`: `{ok: true, userId, newPlayer?, state}`.
## POST /api/act/&lt;action&gt;
Body: form (`application/x-www-form-urlencoded`) or a JSON object (strings, numbers,
booleans; `true` is sent as `"1"`, `false` as unset). Query params work too. Include the
current view (`s`, `moon`, `well`, …, `include` and `catalog`) to get the state for it back.
`clientSeq` (optional, any string or integer) comes back as `clientSeq` in the response
(an integer comes back as a number, so `"007"` becomes `7`), on 200 and 422, so a client can match responses to the
predictions it queued.
Core-loop actions and their params:
| action | params | notes |
|---|---|---|
| `buy-moon` | – | view moves to the new moon |
| `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used |
| `claim` | `claim-well` or `claim-moon` or `claim-wide` (moon id), `redeploy=1` | no scope: claim all (needs `unlocked["claim-all"]`); `redeploy` needs `unlocked.redeploy` |
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well |
| `sell` | `resource`, `amount` or `all=1` | resources and refined items |
The other sections' actions:
| action | params | notes |
|---|---|---|
| `buy-tool` | `id` (store item id) | response has `buyModal` |
| `upgrade` | `tool` (tool id) | `drill=` follows the copy upgraded |
| `sell-tool` | `tool` | idle, nothing to claim, not the last tool (`noSale`) |
| `buy-attachment` | `item` (attachment name) | |
| `equip`, `unequip` | `item` (attachment name), `to` (tool id) | `drill=` follows the copy used |
| `sell-attachment` | `item` | a spare one |
| `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
| `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`) |
| `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 |
Responses:
- `200` `{ok: true, userId, clientSeq?, toast: {message, error: false, kind}, fx: {kind, finished, credits}, reveal?, buyModal?, state}`.
`kind` is the action (for sounds and effects); `finished` lists moons the action mined
out; `credits` is what a market sale earned. `reveal` (after `buy-moon`) and `buyModal`
(after `buy-tool`) are one-shot results, see below.
- `422` rule violation or locked action: `{ok: false, error, toast: {message, error: true, kind: "error"}, state}`.
A locked action (its part of the game not revealed yet) says "Not available yet".
- `400` bad body, `404` unknown action, `429` too many new players, `403` banned,
`500` `{ok: false, error: "Something went wrong"}`.
## GET /api/moon-settings
The procedural-moon look tuned by admins on `/utils/moons` (no player needed): the saved
JSON object, or `null` when none is saved. Merge it over the defaults (`DEFAULTS` in
`web/static/js/terrain.js`; unknown keys and wrong types are ignored, see
`mergeSettings`), then build each moon from its id like `terrain.js` does. Cache it for the
session; the web page also reads it once per page load.
## State
| 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 |
| `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` |
| `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`) |
| `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) |
| `catalogVersion` | a hash of the catalog, for `catalog=` |
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
`newMoon`) are left out unless their section is the view's (`s=`) or in `include=`.
Codes and texts: where the web shows a reason (why a button is disabled), the API gives a
short code, listed with each field, and the web's text next to it.
## Sections
### equipment (`s=equipment`)
```
{tools: [{id, storeItemId, name, code, image, tier, maxTier, count, idle, reach, cycleSecs, yield,
attachments, slots, freeSlots, selected, stake, next, preview, ladder, resale, noSale, bonus, gear}],
drill}
```
- 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.
- `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
tooltip ("Need ₥750 more", "Need 2 Dusk Alloy (you have 0). Make it in the Refinery"),
`shortText` the line under it when credits are short.
- `preview`: what the next tier changes, with the player's bonuses now: `{tier, yieldFrom,
yieldTo, secsFrom, secsTo, reachFrom, reachTo, rateFrom, rateTo, cost, paybackSecs}`
(`rate` is credits per hour; `paybackSecs` null if it never pays back).
- `ladder`: every tier `[{tier, cycleSecs, yield, reach, rate, current, next}]`.
- `resale`: what it sells for; `noSale`: `only-tool`, `claim-first`, `mining` or `""`,
`noSaleText` its text ("Claim its haul first").
- `bonus`: `{speed, yield}` from advisors and its attachments (capped); `gear` the
attachments `[{name, image, effect}]`.
- `drill` (with `drill=<tool id>`, else `null`): the drill panel `{toolId, slots: [{n,
attachment}], spares: [{name, count, cycleSecs, yield, canEquip, why, whyText, price, resale,
confirm}], mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo, ladder,
gear}`. `attachment` is `""` for an empty slot (unequip with `item=<name>&to=<toolId>`); a
spare's `cycleSecs`/`yield` are the drill's with it equipped, `why` is `fitted` (one is on
already) or `full`; `confirm` (panel) is the sell-tool dialog, a spare's the
sell-attachment one. `gear[].effectText` e.g. "+20% speed". While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy
and `deployTo` its best well (a suggestion, as in `buyModal`).
### store (`s=store`)
```
{tools: [{storeItemId, name, code, image, description, price, canAfford, owned, yield, cycleSecs, reach, rate, bestRate, whyText}],
attachments: [{name, image, description, effect: {speed, yield}, effectText, price, canAfford, spare, equipped, resale, whyText}]}
```
`whyText`: the disabled Buy button's tooltip ("Need ₥1,400 more"), `""` when affordable.
`yield` and `cycleSecs` are the base stats the card shows; `rate` is credits per hour it
would earn with the player's advisors, `bestRate` the same for their best tool (0: none).
### refinery (`s=refinery`)
```
{slots: [{n, job: {id, slot, item, qty, start, end, ready, progress} | null}], slotCount, maxSlots,
nextSlot, nextSlotPrice, canBuySlot, free, ready, maxBatch,
recipes: [{item, tier, seconds, price, sellPrice, inputs: [{name, need, have}], inputValue, gain,
maxQty, bulkQty, bulkSecs, bulkBonus, owned, why, whyText}],
stock: [{name, amount}], buySlot, buySlotWhyText}
```
`nextSlotPrice` is 0 at the cap. Per recipe: `inputs` per unit, `maxQty` what one batch
can make now, `bulkSecs` how long a batch of `bulkQty` takes, `bulkBonus` the percent
saved per unit, `sellPrice` one unit's price now (as the market quote), `why` `busy` (no
free slot), `inputs` or `""`, `whyText` its text ("All slots are busy", "Not enough
inputs"). `buySlot` is the buy-slot dialog (`null` at the cap), `buySlotWhyText` why
"+ Slot" is disabled. Refined items sell through `sell` like resources.
### 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 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`.
### leaderboard (`s=leaderboard`)
`{players, top: [{rank, name, credits, me}], you, me: {rank, credits}}`: `you` is the
player's row when they're not in `top` (else `null`); `me` their place either way.
### help (`s=help`)
`{steps: [string], note, newGame}`: the "How to play" steps (the web's text; `note` says
progress is saved on this device). Its button is `new-game`, `newGame` its dialog.
### newMoon (`s=new-moon`)
`{price, canAfford, after, need, types, resources, wellsMin, wellsMax, whyText}`: `after` the
balance after buying, `need` the credits missing, `whyText` why the button is disabled, `types` the moon types it can roll,
`resources` what its wells can hold. Buy with `buy-moon`.
### reveal (in the `buy-moon` response)
```
{moonId, name, kind, description, rating, headline, sub,
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}],
next: {price, canBuy, why, whyText}}
```
`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 `""`.
### buyModal (in the `buy-tool` response)
`{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId,
moonName, wellId, resource, supply, credits, perMin, idle, reason}]}`: up to 3 wells to
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first;
`reason` `best-value`, `most-supply`, `no-drill` or `shared`, `reasonText` its text
("Highest value").
### moon.sale (with `s=moons`, once `unlocked.store`)
```
{total, market, replace, delta, capped, factor, lines: [{resource, supply, rate, value}],
last, running, ready, holdEnd, canSell, why, whyText, confirm}
```
`total` is the offer (`market` × `factor`, capped), `replace` a new moon's price after the
sale, `delta` = total − replace; `ready` finished drills are claimed in the sale.
`why`: `last` (only moon), `running` (drills on it), `hold` (sellable at `holdEnd`) or `""`;
`whyText` the web's text ("Wait for 2 drills to finish", "Just bought: you can sell it in
4:27", at `now`); `confirm` the sell-moon dialog.
### market chart (`s=market`)
`market.chart`: `{item, start, step, prices}`, the selected quote's 24h market rate (before
events, advisors and pressure), one point every `step` ms from `start` (289 points, the last
is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkline).
## Texts
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
the web's title, question, OK button and whether it's destructive, e.g.
`{"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
a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.now`.
Not sent (the client words them from the numbers, like the web's templates):
- moon list status: "N drills", "Idle", "Tapped out"; "A well here has more drills than
supply" (`over`).
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
current drills" (`well.idle[].short`, `here`, `free`).
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
The web's formats:
- credits: `₥` + the amount with thousands commas and up to 2 decimals (`₥1,234.5`);
amounts the same without `₥`. Compact (per hour): `₥1.2M`, `₥45k`, `₥1.5k`, else whole.
- cycles: `3s`, `2m`, `2m 30s`; longer spans `2h 5m`. Countdowns `m:ss`, `h:mm:ss`.
- banner time left: `2d 4h left`, `3h 20m left`, `45m left`.
- bonuses: `+` and the percent with one decimal at most (`+10%`, `+12.5%`).
## Prediction
The client may show results before the server answers; the next state replaces them.
- Stake: a drill takes `tool.cycleSecs` and gets `tool.yield` (× `rules.boosts.yield` of
the well's resource, if any). Each stake carries `start`, `end` and `take` (what claiming
it would get now).
- Sell: `amount` units pay `amount × unpressed × avg`, with
`avg = floor + (1 − floor) × scale × (e^(−p/scale) − e^(−(p+d)/scale)) / d`,
`p` the quote's `pressure`, `d = amount × base` and `floor`, `scale` from
`rules.pressure` (`avg` is `factor` when `d` is 0). `sellAll` is that for the whole
balance. Pressure then becomes `min(p + d, rules.pressure.cap)` and halves every
`halfLifeSecs`.
- Refine: a batch of `qty` takes `round(seconds × qty^bulkExponent / speed)` seconds
(`seconds` per recipe, the rest in `rules.refinery`).
- Every response's `state.now` is the server clock; `clientSeq` matches a response to its
request.
## Example
```
POST /api/act/stake
X-MM-User: 29dea5d5-709d-4c55-a269-cd90e4379fe9
Content-Type: application/json
{"tool": "0fbfcb4f-…", "target": "b9d643c4-…", "moon": "54daa782-…", "well": "b9d643c4-…", "clientSeq": 12}
```
```json
{
"ok": true,
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
"clientSeq": 12,
"toast": {"message": "Drill deployed", "error": false, "kind": "stake"},
"fx": {"kind": "stake", "finished": [], "credits": 0},
"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},
"credits": 1644.63,
"resources": [{"name": "Azurium", "amount": 0}, {"name": "Nebulance", "amount": 0}],
"refined": [],
"moonPrice": 400,
"unlocked": {"moons": true, "equipment": true, "market": true, "store": true, "refinery": false, "advisors": false,
"leaderboard": true, "help": true, "new-moon": true, "claim-all": false, "redrill": true, "redeploy": true},
"shine": ["equipment", "store"],
"ready": 0, "idle": 0, "nextEnd": 1792391819259,
"moons": [{"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70},
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47}],
"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-…"}],
"stakes": [{"id": "efc5071d-…", "moonId": "54daa782-…", "wellId": "b9d643c4-…", "toolId": "0fbfcb4f-…",
"resource": "Nebulance", "amount": 2, "reach": 1, "start": 1792391815259, "end": 1792391819259,
"durationSecs": 4, "ready": false, "take": 2}],
"moon": {"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "description": "A bleached mauve satellite …",
"rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70}, "finished": false, "ready": 0, "nextEnd": 1792391819259,
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47,
"drills": [{"id": "efc5071d-…", "…": "…", "gets": 2, "here": 2, "state": "ok", "fate": "Prospector Drone PD-1 T0: +2"}],
"ready": 0, "readyAmount": 0, "nextEnd": 1792391819259, "cap": 47, "label": "PD-1 T0", "wide": [],
"selected": true, "redrill": 0, "demand": 2, "remain": 45, "dryAfter": 0, "dryAt": null}],
"wide": null},
"well": {"id": "b9d643c4-…", "resource": "Nebulance", "rate": 11.157, "idle": [], "better": null},
"market": {"quotes": [{"item": "Azurium", "refined": false, "balance": 0, "price": 6.864, "rate": 6.864, "boost": 1,
"advisor": 1, "pressure": 0, "factor": 1, "open": 6.899, "change": -0.501,
"high": 7.151, "low": 5.786, "value": 0}],
"selected": "Azurium", "total": 0},
"banner": [],
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"},
"catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
"catalogVersion": "9adb4eabab929f5d"
}
}
```
## Section examples
From a live server (long ids and arrays shortened).
`GET /api/state?s=equipment&drill=7e40…`, `state.equipment`:
```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,
"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,
"rateFrom": 24015.57, "rateTo": 32020.76, "cost": 900, "paybackSecs": 404.7},
"ladder": [{"tier": 0, "cycleSecs": 3, "yield": 2, "reach": 1, "rate": 16010.38, "current": false, "next": false}, "…"],
"resale": 1175, "noSale": "", "bonus": {"speed": 1.2, "yield": 1},
"gear": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png", "effect": {"speed": 1.2, "yield": 1}}]}],
"drill": {"toolId": "7e40…", "slots": [{"n": 1, "attachment": "Turbo Core"}, {"n": 2, "attachment": ""}],
"spares": [{"name": "Diamond Bit", "count": 1, "cycleSecs": 3, "yield": 2.3, "canEquip": true, "why": "",
"price": 4000, "resale": 2000}],
"mining": false, "resale": 750, "noSale": "", "idleSame": 0, "idleBest": "", "deployTo": null,
"ladder": ["…"], "gear": ["…"]}}
```
`s=store`, `state.store`:
```json
{"tools": [{"storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", "image": "/static/assets/items/shovel.png",
"description": "A hovering scout drone that skims ore off the surface.", "price": 1500, "canAfford": true,
"owned": 1, "yield": 2, "cycleSecs": 4, "reach": 1, "rate": 12007.78, "bestRate": 12007.78}, "…"],
"attachments": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png",
"description": "An overdriven motor core. Runs hot, cycles fast.", "effect": {"speed": 1.2, "yield": 1},
"price": 3000, "canAfford": true, "spare": 0, "equipped": 0, "resale": 1500}, "…"]}
```
`s=refinery`, `state.refinery`:
```json
{"slots": [{"n": 1, "job": {"id": "1155…", "slot": 1, "item": "Dusk Alloy", "qty": 3, "start": 1792402355103,
"end": 1792402357103, "ready": false, "progress": 0.004}}],
"slotCount": 1, "maxSlots": 4, "nextSlot": 2, "nextSlotPrice": 1500, "canBuySlot": true, "free": 0, "ready": 0, "maxBatch": 25,
"recipes": [{"item": "Dusk Alloy", "tier": 1, "seconds": 1, "price": 30, "sellPrice": 29.52,
"inputs": [{"name": "Shadowstone", "need": 2, "have": 494}, {"name": "Sollux", "need": 2, "have": 494}],
"inputValue": 15, "gain": 100, "maxQty": 25, "bulkQty": 25, "bulkSecs": 7, "bulkBonus": 72.4,
"owned": 0, "why": "busy"}, "…"],
"stock": []}
```
`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": "",
"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": ""}, "…"]}
```
`s=leaderboard`: `{"players": 2, "top": [{"rank": 1, "name": "…", "credits": 73579.5, "me": true}, "…"], "you": null, "me": {"rank": 1, "credits": 73579.5}}`
`s=new-moon`, `state.newMoon`:
```json
{"price": 400, "canAfford": true, "after": 73179.5, "need": 0, "types": ["Caelusium", "Argentis", "…"],
"resources": ["Azurium", "Nebulance", "Novafor", "Shadowstone", "Sollux"], "wellsMin": 2, "wellsMax": 5}
```
`POST /api/act/buy-moon`, `reveal`:
```json
{"moonId": "9b79…", "name": "Lithara-η", "kind": "Lumineer", "description": "…",
"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": ""}}
```
`POST /api/act/buy-tool`, `buyModal`:
```json
{"toolId": "e79d…", "storeItemId": 1, "cycleSecs": 4, "yield": 2, "reach": 1, "noMoons": false, "allEmpty": false,
"suggestions": [{"moonId": "9b79…", "moonName": "Lithara-η", "wellId": "92cf…", "resource": "Azurium", "supply": 37,
"credits": 13.6, "perMin": 204.0, "idle": true, "reason": "best-value"}, "…"]}
```
`s=moons&moon=…`, `state.moon.sale`:
```json
{"total": 91, "market": 919.54, "replace": 400, "delta": -309, "capped": false, "factor": 0.1,
"lines": [{"resource": "Nebulance", "supply": 64, "rate": 10.55, "value": 675.48}, "…"],
"last": false, "running": 0, "ready": 0, "holdEnd": 1792402983084, "canSell": false, "why": "hold"}
```
`s=market&pick=Azurium`, `state.market` (one quote):
```json
{"selected": "Azurium", "held": 6, "best": "Cryo Cell", "worst": "Dusk Alloy", "total": 17038.73,
"chart": {"item": "Azurium", "start": 1792315800000, "step": 300000, "prices": [7.03, "… 289 points"]},
"quotes": [{"item": "Azurium", "refined": false, "balance": 500, "price": 6.80, "rate": 6.80, "boost": 1, "advisor": 1,
"pressure": 0, "factor": 1, "open": 7.03, "change": -3.33, "high": 7.15, "low": 5.79, "value": 3400.28,
"symbol": "AZU", "unpressed": 6.80, "base": 6.5, "sellAll": 3384.06, "spark": [7.03, "… 97 points"]}, "…"]}
```
+1049
View File
File diff suppressed because it is too large. Load diff
+304
View File
@@ -0,0 +1,304 @@
package main
// API check (make check): builds the JSON state (api.go) for every view of the template
// check's in-memory states and checks a few fields. Run with `go test -run API`.
import (
"encoding/json"
"testing"
)
func TestAPI(t *testing.T) {
unlocked := newPlayerState()
unlocked.Onboarding = []string{"all"}
asides := map[bool]bool{}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
p := s.page(v.resolve(s))
testRanking(p)
a := apiState(p)
if _, err := json.Marshal(a); err != nil {
t.Fatalf("marshal %+v: %v", v, err)
}
checkAPISections(t, s, p.V, a)
checkAPITexts(t, p.V, a)
for section, present := range map[string]bool{"equipment": a.Equipment != nil, "store": a.Store != nil,
"refinery": a.Refinery != nil, "advisors": a.Advisors != nil, "leaderboard": a.Leaderboard != nil,
"help": a.Help != nil, "new-moon": a.NewMoon != nil} {
if present != (p.V.Section == section) {
t.Errorf("view %+v: %s block present %v", v, section, present)
}
}
if a.Rules == nil || a.Catalog == nil || len(a.Catalog.Tools) != len(s.Config.StoreItems) {
t.Errorf("view %+v: rules or catalog missing", v)
}
if (len(a.Jobs) > 0) && !s.Unlocked("refinery") {
t.Errorf("view %+v: refinery jobs before the refinery is open", v)
}
if a.Market != nil {
for _, q := range a.Market.Quotes {
if q.SellAll > q.Value+1e-6 || (q.Balance > 0 && q.SellAll <= 0) {
t.Errorf("quote %s: sell all %v, value %v", q.Item, q.SellAll, q.Value)
}
}
if (a.Market.Chart != nil) != (p.V.Section == "market") {
t.Errorf("view %+v: market chart %v", v, a.Market.Chart != nil)
}
if c := a.Market.Chart; c != nil && (len(c.Prices) != marketPoints || c.Item != a.Market.Selected) {
t.Errorf("view %+v: chart of %s with %d points", v, c.Item, len(c.Prices))
}
}
if a.Moon != nil && (a.Moon.Sale != nil) != (s.Unlocked("store") && p.Moon.Sale != nil) {
t.Errorf("view %+v: moon sale %v", v, a.Moon.Sale != nil)
}
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 == "") {
t.Errorf("view %+v: reveal %+v", v, r)
}
}
for _, tool := range s.Tools {
if m := apiBuyModal(s.buySuggestions(tool.ID)); m == nil || m.ToolID != tool.ID {
t.Errorf("buy modal for %s: %+v", tool.ID, m)
}
}
// Every section block at once (include=all).
var extra []*Page
for _, section := range apiIncludes("all") {
ev := p.V
ev.Section = section
if ev = ev.resolve(s); ev.Section == section {
ep := s.page(ev)
testRanking(ep)
extra = append(extra, ep)
}
}
all := apiState(p, extra...)
for _, ep := range extra {
checkAPISections(t, s, ep.V, all)
}
checkAPITexts(t, p.V, all)
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)
}
if len(a.Stakes) != len(s.Stakes) || len(a.Tools) != len(s.Tools) {
t.Errorf("view %+v: %d stakes, %d tools", v, len(a.Stakes), len(a.Tools))
}
for _, st := range a.Stakes {
if st.End-st.Start != int64(st.Secs)*1000 {
t.Errorf("stake %s: end - start is not its duration", st.ID)
}
}
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)
}
// The catalog: a stable version, skipped with catalog=0 or catalog=<version> only.
s := veteranState()
a, b := apiState(s.page(View{}.resolve(s))), apiState(s.page(View{}.resolve(s)))
if a.CatalogVersion == "" || a.CatalogVersion != b.CatalogVersion {
t.Errorf("catalog versions %q, %q", a.CatalogVersion, b.CatalogVersion)
}
for param, kept := range map[string]bool{"": true, "1": true, "stale": true, "0": false, a.CatalogVersion: false} {
c := apiState(s.page(View{}.resolve(s)))
if c.skipCatalog(param); (c.Catalog != nil) != kept {
t.Errorf("catalog=%q: catalog present %v", param, c.Catalog != nil)
}
}
}
// checkAPITexts checks that ratings carry their label and "top N%", every reason code its
// text and every confirm its question, as the web shows them.
func checkAPITexts(t *testing.T, v View, a *APIState) {
t.Helper()
fail := func(what string, x any) { t.Errorf("view %+v: %s: %+v", v, what, x) }
rating := func(r *APIRating) {
if r != nil && (r.Label == "" || r.Top < 1) {
fail("rating", r)
}
}
for _, m := range append(append([]APIMoonListItem{}, a.Moons...), a.Past...) {
rating(m.Rating)
if m.Finished && m.FinishedOn == "" {
fail("past moon date", m)
}
}
if m := a.Moon; m != nil {
rating(m.Rating)
for _, w := range m.Wells {
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 != "") {
fail("drill fate", d)
}
}
}
if sale := m.Sale; sale != nil && ((sale.Why != "") != (sale.WhyText != "") || sale.Confirm.Text == "") {
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)
}
}
if c := a.Catalog; c != nil {
if len(c.Reach) != len(c.Resources) {
fail("reach texts", c.Reach)
}
for _, at := range c.Attachments {
if at.EffectText == "" {
fail("attachment effect", at)
}
}
}
if e := a.Equipment; e != nil {
for _, r := range e.Tools {
if (r.NoSale != "") != (r.NoSaleText != "") || r.Next != nil && (r.Next.Blocked != "") != (r.Next.BlockedText != "") {
fail("tool row texts", r)
}
}
if d := e.Drill; d != nil {
if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" {
fail("drill panel texts", d)
}
for _, sp := range d.Spares {
if (sp.Why != "") != (sp.WhyText != "") || sp.Confirm.Text == "" {
fail("spare texts", sp)
}
}
}
}
if st := a.Store; st != nil {
for _, tool := range st.Tools {
if tool.CanAfford == (tool.WhyText != "") {
fail("store tool text", tool)
}
}
}
if r := a.Refinery; r != nil {
if (r.NextSlotPrice > 0) != (r.BuySlot != nil) {
fail("buy slot confirm", r.BuySlot)
}
for _, rc := range r.Recipes {
if (rc.Why != "") != (rc.WhyText != "") {
fail("recipe texts", rc)
}
}
}
if ad := a.Advisors; ad != nil {
if (ad.NextSeatPrice > 0) != (ad.BuySeat != nil) {
fail("buy seat confirm", ad.BuySeat)
}
for _, up := range ad.Advisors {
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 {
fail("new game confirm", h.NewGame)
}
}
// testRanking stands in for the leaderboard the server loads (loadRanking).
func testRanking(p *Page) {
if p.V.Section == "leaderboard" {
p.Ranking = &LeaderboardPage{Players: 12, Top: []LeaderboardRow{{Rank: 1, Name: "Top Miner", Balance: 9000}},
You: &LeaderboardRow{Rank: 12, Name: "Test Miner", Balance: 10, Me: true}}
}
}
// checkAPISections checks that the block of a view's section is in the state, as the
// section's view model has it.
func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
t.Helper()
switch v.Section {
case "equipment":
if a.Equipment == nil || len(a.Equipment.Tools) == 0 && len(s.Tools) > 0 {
t.Errorf("view %+v: equipment %+v", v, a.Equipment)
} else if (a.Equipment.Drill != nil) != (v.Drill != "") {
t.Errorf("view %+v: drill panel %v", v, a.Equipment.Drill != nil)
} else if d := a.Equipment.Drill; d != nil && len(d.Slots) != cfg().Attachments.SlotsPerTool {
t.Errorf("view %+v: %d slots", v, len(d.Slots))
}
case "store":
if a.Store == nil || len(a.Store.Tools) != len(s.Config.StoreItems) || len(a.Store.Attachments) != len(cfg().Attachments.Items) {
t.Errorf("view %+v: store %+v", v, a.Store)
}
case "refinery":
if a.Refinery == nil || len(a.Refinery.Recipes) != len(cfg().Refinery.Items) || len(a.Refinery.Slots) < s.RefinerySlots {
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 {
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)
}
case "help":
if a.Help == nil || len(a.Help.Steps) == 0 {
t.Errorf("view %+v: help %+v", v, a.Help)
}
case "new-moon":
if a.NewMoon == nil || a.NewMoon.Price != s.MoonPrice() || a.NewMoon.WellsMin < 1 {
t.Errorf("view %+v: new moon %+v", v, a.NewMoon)
}
case "market":
if a.Market == nil || a.Market.Chart == nil {
t.Errorf("view %+v: market chart missing", v)
}
}
}
+32 -5
View File
@@ -10,6 +10,7 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings" "strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -230,16 +231,42 @@ type SpareGear struct {
Why string // why it can't be equipped Why string // why it can't be equipped
} }
func (s *State) drillPanel(row ToolRow) *DrillPanel { // noSale is why a tool can't be sold (only-tool, claim-first, mining), "" if it can.
d := &DrillPanel{ToolRow: row, Mining: row.Stake != nil, Resale: row.Item.ToolResale(row.Tier)} func (s *State) noSale(row ToolRow) string {
switch { switch {
case len(s.Tools) <= 1: case len(s.Tools) <= 1:
d.NoSale = "You can't sell your only tool" return "only-tool"
case row.Stake != nil && row.Stake.Ready: case row.Stake != nil && row.Stake.Ready:
d.NoSale = "Claim its haul first" return "claim-first"
case row.Stake != nil: case row.Stake != nil:
d.NoSale = "Still mining. Sell it once it's idle" return "mining"
} }
return ""
}
// Confirm is the question before selling one (the Sell button's confirm dialog).
func (sg SpareGear) Confirm() string {
return fmt.Sprintf("Sell a spare %s for %s? You paid %s.", sg.Name, formatCredits(sg.Resale()), formatCredits(sg.Price))
}
// SellConfirm is the question before selling the drill (its Sell button's confirm dialog).
func (d *DrillPanel) SellConfirm() string {
q := fmt.Sprintf("Sell this %s (T%d) for %s?", d.Item.Name, d.Tier, formatCredits(d.Resale))
if len(d.Gear) > 0 {
q += " Its attachments go back to your inventory."
}
return q
}
var noSaleText = map[string]string{
"only-tool": "You can't sell your only tool",
"claim-first": "Claim its haul first",
"mining": "Still mining. Sell it once it's idle",
}
func (s *State) drillPanel(row ToolRow) *DrillPanel {
d := &DrillPanel{ToolRow: row, Mining: row.Stake != nil, Resale: row.Item.ToolResale(row.Tier)}
d.NoSale = noSaleText[s.noSale(row)]
for i := range cfg().Attachments.SlotsPerTool { for i := range cfg().Attachments.SlotsPerTool {
slot := SlotGear{N: i + 1} slot := SlotGear{N: i + 1}
if i < len(row.Gear) { if i < len(row.Gear) {
+13 -5
View File
@@ -114,12 +114,12 @@ func (cfg *GameConfig) validateBonuses() error {
// ------------------------------------------------------------------ in a transaction // ------------------------------------------------------------------ in a transaction
// loadBonus reads the player's advisors and, with a tool id, the tool's attachments, and // loadBonus reads the player's seated 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 // combines them. Callers that pass a tool have locked its row; the advisor rows are
// share-locked so a hire can't change them halfway through the action. // share-locked so a hire can't change them halfway through the action.
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) { func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
rows, err := tx.Query(ctx, ` rows, err := tx.Query(ctx, `
select advisor from advisor_hire where user_id = $1 order by advisor for share`, userID) select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
if err != nil { if err != nil {
return noBonus, err return noBonus, err
} }
@@ -143,20 +143,28 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, er
// ------------------------------------------------------------------ state // ------------------------------------------------------------------ state
// loadBonusState loads the player's attachments, advisors and advisor seats into the state. // loadBonusState loads the player's attachments, advisors (hired, and the seated ones) and
// advisor seats into the state.
func loadBonusState(ctx context.Context, db querier, s *State) error { func loadBonusState(ctx context.Context, db querier, s *State) error {
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats) err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
if err != nil { if err != nil {
return err return err
} }
rows, err := db.Query(ctx, ` rows, err := db.Query(ctx, `
select advisor from advisor_hire where user_id = $1 order by hired_at, advisor`, s.UserID) select advisor, coalesce(seat, 0) from advisor_hire where user_id = $1
order by seat nulls last, hired_at, advisor`, s.UserID)
if err != nil { if err != nil {
return err return err
} }
if s.Advisors, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil { if s.Hires, err = pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire]); err != nil {
return err 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, ` rows, err = db.Query(ctx, `
select coalesce(inventory_item_id::text, ''), item from attachment select coalesce(inventory_item_id::text, ''), item from attachment
where user_id = $1 order by item`, s.UserID) where user_id = $1 order by item`, s.UserID)
+2
View File
@@ -1,3 +1,5 @@
name: moonminers
# Local development database. Run the app itself with `make dev`. # Local development database. Run the app itself with `make dev`.
services: services:
db: db:
+7 -2
View File
@@ -177,6 +177,10 @@ type BannerItem struct {
Level string // info, warning, success, event Level string // info, warning, success, event
Text string Text string
Left string // "3h left" Left string // "3h left"
// For API clients: the announcement or live event it shows and when it ends.
Kind string // announcement, event
ID int
EndsAt time.Time
} }
// loadBanner returns the active announcements, then the active events. // loadBanner returns the active announcements, then the active events.
@@ -194,7 +198,7 @@ func loadBanner(ctx context.Context, q querier) ([]BannerItem, error) {
} }
var items []BannerItem var items []BannerItem
for _, a := range anns { for _, a := range anns {
items = append(items, BannerItem{Level: a.Level, Text: a.Message}) items = append(items, BannerItem{Level: a.Level, Text: a.Message, Kind: "announcement", ID: a.ID, EndsAt: a.EndsAt})
} }
rows, err = q.Query(ctx, "select "+liveEventColumns+` from live_event rows, err = q.Query(ctx, "select "+liveEventColumns+` from live_event
@@ -207,7 +211,8 @@ func loadBanner(ctx context.Context, q querier) ([]BannerItem, error) {
return nil, err return nil, err
} }
for _, e := range events { for _, e := range events {
items = append(items, BannerItem{Level: "event", Text: e.Headline(), Left: timeLeft(e.EndsAt.Sub(now))}) items = append(items, BannerItem{Level: "event", Text: e.Headline(), Left: timeLeft(e.EndsAt.Sub(now)),
Kind: "event", ID: e.ID, EndsAt: e.EndsAt})
} }
return items, nil return items, nil
} }
+96 -12
View File
@@ -113,6 +113,12 @@ type State struct {
Balance float64 Balance float64
Resources []ResourceBalance Resources []ResourceBalance
Moons []Moon 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 Tools []Tool
Stakes []Stake Stakes []Stake
Now time.Time Now time.Time
@@ -130,10 +136,12 @@ type State struct {
Pressure map[string]float64 // the player's selling pressure by item, now (market.go) Pressure map[string]float64 // the player's selling pressure by item, now (market.go)
// Bonuses (bonus.go): attachments equipped on each tool and spare ones (by kind), // Bonuses (bonus.go): attachments equipped on each tool and spare ones (by kind),
// advisors hired, advisor seats owned. // seated advisors (their bonus counts), every advisor hired with their seat, advisor
// seats owned.
Fitted map[string][]string Fitted map[string][]string
Spare map[string]int Spare map[string]int
Advisors []string Advisors []string
Hires []AdvisorHire // seated first, by seat
AdvisorSeats int AdvisorSeats int
// Onboarding flags (onboarding.go): which parts of the game are revealed. // Onboarding flags (onboarding.go): which parts of the game are revealed.
@@ -410,9 +418,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
for _, st := range s.Stakes { for _, st := range s.Stakes {
delete(s.LastWell, st.ToolID) delete(s.LastWell, st.ToolID)
} }
if err := loadPastMoons(ctx, db, s); err != nil { loadPastMoons(s)
return nil, err
}
if err := loadRefinery(ctx, db, s); err != nil { if err := loadRefinery(ctx, db, s); err != nil {
return nil, err return nil, err
} }
@@ -434,14 +440,31 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
return s, nil return s, nil
} }
// loadPastMoons marks the finished moons and adds what was mined on each. Supply only goes // loadView loads the player's state for a view (with the page of past moons it shows) and
// down and every stake gets claimed, so a finished moon stays finished. // resolves the view.
func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error { 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) {
busy := map[string]bool{} busy := map[string]bool{}
for _, st := range s.Stakes { for _, st := range s.Stakes {
busy[st.MoonID] = true busy[st.MoonID] = true
} }
past := map[string]*Moon{} s.PastTotal = 0
for i := range s.Moons { for i := range s.Moons {
m := &s.Moons[i] m := &s.Moons[i]
m.Finished = len(m.Wells) > 0 && !busy[m.ID] m.Finished = len(m.Wells) > 0 && !busy[m.ID]
@@ -451,13 +474,51 @@ func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
} }
} }
if m.Finished { if m.Finished {
past[m.ID] = m s.PastTotal++
} }
} }
if len(past) == 0 { }
// 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 {
return nil return nil
} }
rows, err := db.Query(ctx, ` 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, select resource_well.source_id, resource.name,
sum(coalesce(claim_well.amount, claim_event.claim_amount)), max(claim_event.created_at) sum(coalesce(claim_well.amount, claim_event.claim_amount)), max(claim_event.created_at)
from claim_event from claim_event
@@ -465,9 +526,9 @@ func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
left join claim_well on claim_well.claim_event_id = claim_event.id 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_well on resource_well.id = coalesce(claim_well.well_id, staking_event.well_id)
join resource on resource.id = resource_well.resource_id join resource on resource.id = resource_well.resource_id
where staking_event.user_id = $1 where staking_event.user_id = $1 and resource_well.source_id::text = any($2)
group by resource_well.source_id, resource.name group by resource_well.source_id, resource.name
order by resource.name`, s.UserID) order by resource.name`, s.UserID, load)
if err != nil { if err != nil {
return err return err
} }
@@ -486,6 +547,29 @@ func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
return err 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 // moonFinished reports whether a moon has just been mined out: its wells are empty and
// nothing on it is left to claim. // nothing on it is left to claim.
func moonFinished(ctx context.Context, tx pgx.Tx, moonID string) (bool, error) { func moonFinished(ctx context.Context, tx pgx.Tx, moonID string) (bool, error) {
+3 -2
View File
@@ -134,14 +134,15 @@ func markSeen(ctx context.Context, db *pgxpool.Pool, userID, section string) err
return err return err
} }
// actionGates is the feature each action needs; handleAction refuses the rest. // actionGates is the feature each action needs; runAction refuses the rest.
var actionGates = map[string]string{ var actionGates = map[string]string{
"sell-moon": "store", "sell-tool": "store", "sell-attachment": "store", "sell-moon": "store", "sell-tool": "store", "sell-attachment": "store",
"buy-tool": "store", "upgrade": "store", "buy-attachment": "store", "buy-tool": "store", "upgrade": "store", "buy-attachment": "store",
"equip": "store", "unequip": "store", "redrill": "market", "new-game": "help", "equip": "store", "unequip": "store", "redrill": "market", "new-game": "help",
"sell": "market", "sell": "market",
"refine": "refinery", "collect": "refinery", "buy-slot": "refinery", "refine": "refinery", "collect": "refinery", "buy-slot": "refinery",
"hire": "advisors", "buy-seat": "advisors", "hire": "advisors", "buy-seat": "advisors", "seat-advisor": "advisors", "unseat-advisor": "advisors",
"swap-advisors": "advisors", "dismiss-advisor": "advisors",
"buy-moon": "new-moon", "buy-moon": "new-moon",
} }
+16
View File
@@ -553,6 +553,22 @@ type RefineryPage struct {
Stock []RefinedBalance // refined items owned, in config order Stock []RefinedBalance // refined items owned, in config order
} }
// SlotConfirm is the question before buying the next slot.
func (p *RefineryPage) SlotConfirm() string {
return fmt.Sprintf("Add refinery slot %d for %s? Each slot runs one batch at a time.", p.NextSlot, formatCredits(p.NextSlotPrice))
}
// Why is why a recipe can't be started (its buttons' tooltip), "" if it can.
func (p *RefineryPage) Why(card RecipeCard) string {
switch {
case p.Free == 0:
return "All slots are busy"
case card.MaxQty == 0:
return "Not enough inputs"
}
return ""
}
type SlotRow struct { type SlotRow struct {
N int N int
Job *JobRow Job *JobRow
+29 -6
View File
@@ -52,6 +52,17 @@ type MoonReveal struct {
Traits []string // notable traits and their rarity Traits []string // notable traits and their rarity
MaxSupply float64 // the biggest well a moon can have: a full bar MaxSupply float64 // the biggest well a moon can have: a full bar
FullAt float64 // wells from here count as full 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"} var countWords = []string{"", "one", "two", "three", "four", "five", "six", "seven", "eight"}
@@ -68,7 +79,14 @@ func (p *Page) moonReveal() *MoonReveal {
for i, w := range p.Moon.Wells { for i, w := range p.Moon.Wells {
wells[i] = wellSupply{Resource: w.Resource, Supply: w.Supply} wells[i] = wellSupply{Resource: w.Resource, Supply: w.Supply}
} }
r := &MoonReveal{MoonDetail: p.Moon} 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 == ""
if p.Moon.Rating != nil { if p.Moon.Rating != nil {
r.Rating = *p.Moon.Rating r.Rating = *p.Moon.Rating
} else { } else {
@@ -161,7 +179,15 @@ func (p *Page) moonReveal() *MoonReveal {
// WellRange is how many wells a new moon can have, e.g. "2–5". // WellRange is how many wells a new moon can have, e.g. "2–5".
func (s *State) WellRange() string { func (s *State) WellRange() string {
lo, hi := 0, 0 lo, hi := wellCountRange()
if lo == hi {
return strconv.Itoa(lo)
}
return strconv.Itoa(lo) + "–" + strconv.Itoa(hi)
}
// wellCountRange is the fewest and most wells a new moon can have.
func wellCountRange() (lo, hi int) {
for n, w := range cfg().WellCountWeights { for n, w := range cfg().WellCountWeights {
if w > 0 { if w > 0 {
if lo == 0 || n < lo { if lo == 0 || n < lo {
@@ -170,8 +196,5 @@ func (s *State) WellRange() string {
hi = max(hi, n) hi = max(hi, n)
} }
} }
if lo == hi { return lo, hi
return strconv.Itoa(lo)
}
return strconv.Itoa(lo) + "–" + strconv.Itoa(hi)
} }
+137 -63
View File
@@ -10,6 +10,7 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/netip"
"os" "os"
"regexp" "regexp"
"strconv" "strconv"
@@ -74,6 +75,8 @@ func serve(ctx context.Context) error {
mux.HandleFunc("GET /api/moon-settings", s.handleGetMoonSettings) mux.HandleFunc("GET /api/moon-settings", s.handleGetMoonSettings)
mux.HandleFunc("POST /api/moon-settings", s.handleSaveMoonSettings) mux.HandleFunc("POST /api/moon-settings", s.handleSaveMoonSettings)
mux.HandleFunc("GET /api/leaderboard", s.handleLeaderboard) mux.HandleFunc("GET /api/leaderboard", s.handleLeaderboard)
mux.HandleFunc("GET /api/state", s.handleAPIState)
mux.HandleFunc("POST /api/act/{action}", s.handleAPIAction)
addr := ":" + env("PORT", "3000") addr := ":" + env("PORT", "3000")
srv := &http.Server{ srv := &http.Server{
@@ -216,21 +219,35 @@ func (l *rateLimiter) allow(r *http.Request) bool {
} }
// clientIP is the connection's address, or with TRUST_PROXY=1 the address the proxy saw // 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). // (the last X-Forwarded-For entry; earlier ones are client-supplied), normalised.
func clientIP(r *http.Request) string { func clientIP(r *http.Request) string {
if os.Getenv("TRUST_PROXY") == "1" { if os.Getenv("TRUST_PROXY") == "1" {
if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 { if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
parts := strings.Split(xff[len(xff)-1], ",") parts := strings.Split(xff[len(xff)-1], ",")
if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" { if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
return ip return normalIP(ip)
} }
} }
} }
host, _, err := net.SplitHostPort(r.RemoteAddr) return normalIP(r.RemoteAddr)
if err != nil { }
return 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
} }
return host ip, err := netip.ParseAddr(s)
if err != nil {
return s
}
ip = ip.Unmap().WithZone("")
if ip.IsLoopback() {
return "loopback"
}
return ip.String()
} }
func serverError(w http.ResponseWriter, err error) { func serverError(w http.ResponseWriter, err error) {
@@ -316,12 +333,12 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
serverError(w, err) serverError(w, err)
return return
} }
state, err := loadState(r.Context(), s.db, userID) state, v, err := loadView(r.Context(), s.db, userID, viewFromForm(r.FormValue))
if err != nil { if err != nil {
serverError(w, err) serverError(w, err)
return return
} }
page := state.page(viewFromForm(r.FormValue).resolve(state)) page := state.page(v)
if err := s.loadRanking(r.Context(), page); err != nil { if err := s.loadRanking(r.Context(), page); err != nil {
serverError(w, err) serverError(w, err)
return return
@@ -336,21 +353,34 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
s.render(w, "page", page) s.render(w, "page", page)
} }
// handleAction runs a game action and re-renders the screen. The request carries the // actionResult is what runAction did, for the response (HTML regions or JSON).
// current view (s, moon, well, tab), which stays unless the action moves on. type actionResult struct {
func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID string) { Action string // the action, "refused" (onboarding gate) or "new-game" (left to the caller)
ctx := r.Context() Err error // a GameError: the action was refused or broke a rule
view := viewFromForm(r.FormValue) Message string
View View // the view to show next (the request's, unless the action moves on)
BoughtTool string // a tool just bought (the buy modal)
Upgraded string // a tool just upgraded (its toast)
Finished []string // moons a claim mined out
SaleCredits float64 // what a market sale earned (the sell ticket's payout effect)
}
// errUnknownAction is runAction's error for an action that doesn't exist.
var errUnknownAction = errors.New("unknown action")
// runAction runs a game action: its onboarding gate, then its rule in game.go. get reads
// the request's parameters, including the current view (s, moon, well, tab), which stays
// unless the action moves on. new-game only passes the gate; the caller creates the player.
func (s *server) runAction(ctx context.Context, userID, action string, get func(string) string) (actionResult, error) {
view := viewFromForm(get)
var message, boughtTool, upgraded string var message, boughtTool, upgraded string
var finished []string // moons a claim mined out var finished []string
var saleCredits float64 // what a market sale earned (the sell ticket's payout effect) var saleCredits float64
var err error var err error
action := r.PathValue("action")
// Actions of parts of the game not revealed yet are refused (onboarding.go). // Actions of parts of the game not revealed yet are refused (onboarding.go).
if err = actionAllowed(ctx, s.db, userID, action, r.FormValue("redeploy") == "1"); err != nil && !isGameError(err) { if err = actionAllowed(ctx, s.db, userID, action, get("redeploy") == "1"); err != nil && !isGameError(err) {
serverError(w, err) return actionResult{}, err
return
} }
if err != nil { if err != nil {
action = "refused" action = "refused"
@@ -365,45 +395,45 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
case "sell-moon": case "sell-moon":
var name string var name string
var earned float64 var earned float64
if name, earned, err = sellMoon(ctx, s.db, userID, r.FormValue("sell-moon")); err == nil { if name, earned, err = sellMoon(ctx, s.db, userID, get("sell-moon")); err == nil {
message, view = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned)), View{Section: "moons"} message, view = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned)), View{Section: "moons"}
} }
case "sell-tool": case "sell-tool":
var name string var name string
var earned float64 var earned float64
if name, earned, err = sellTool(ctx, s.db, userID, r.FormValue("tool")); err == nil { if name, earned, err = sellTool(ctx, s.db, userID, get("tool")); err == nil {
message = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned)) message = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned))
} }
case "sell-attachment": case "sell-attachment":
item := r.FormValue("item") item := get("item")
var earned float64 var earned float64
if earned, err = sellAttachment(ctx, s.db, userID, item); err == nil { if earned, err = sellAttachment(ctx, s.db, userID, item); err == nil {
message = fmt.Sprintf("Sold %s for %s", item, formatCredits(earned)) message = fmt.Sprintf("Sold %s for %s", item, formatCredits(earned))
} }
case "buy-tool": case "buy-tool":
id, _ := strconv.Atoi(r.FormValue("id")) id, _ := strconv.Atoi(get("id"))
if boughtTool, err = buyTool(ctx, s.db, userID, id); err == nil { if boughtTool, err = buyTool(ctx, s.db, userID, id); err == nil {
message = "Tool purchased" message = "Tool purchased"
} }
case "upgrade": case "upgrade":
var tool string var tool string
if tool, err = upgradeTool(ctx, s.db, userID, r.FormValue("tool")); err == nil { if tool, err = upgradeTool(ctx, s.db, userID, get("tool")); err == nil {
message, upgraded = "Tool upgraded", tool message, upgraded = "Tool upgraded", tool
view.Drill = followTool(view.Drill, r.FormValue("tool"), tool) view.Drill = followTool(view.Drill, get("tool"), tool)
} }
case "stake": case "stake":
if err = stake(ctx, s.db, userID, r.FormValue("tool"), r.FormValue("target")); err == nil { if err = stake(ctx, s.db, userID, get("tool"), get("target")); err == nil {
message = "Drill deployed" message = "Drill deployed"
} }
case "claim": case "claim":
// Claims every finished drill on the given well or moon (or everywhere); claim-wide // Claims every finished drill on the given well or moon (or everywhere); claim-wide
// claims a moon's drills that mine several of its wells. // claims a moon's drills that mine several of its wells.
scope := ClaimScope{ scope := ClaimScope{
MoonID: r.FormValue("claim-moon"), MoonID: get("claim-moon"),
WellID: r.FormValue("claim-well"), WellID: get("claim-well"),
Redeploy: r.FormValue("redeploy") == "1", Redeploy: get("redeploy") == "1",
} }
if id := r.FormValue("claim-wide"); id != "" { if id := get("claim-wide"); id != "" {
scope.MoonID, scope.Wide = id, true scope.MoonID, scope.Wide = id, true
} }
var res ClaimResult var res ClaimResult
@@ -453,13 +483,13 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
} }
case "redrill": case "redrill":
var n int var n int
if n, err = redrill(ctx, s.db, userID, r.FormValue("redrill-well")); err == nil { if n, err = redrill(ctx, s.db, userID, get("redrill-well")); err == nil {
message = fmt.Sprintf("Redrilled %s", plural(n, "drill")) message = fmt.Sprintf("Redrilled %s", plural(n, "drill"))
} }
case "sell": case "sell":
resource := r.FormValue("resource") resource := get("resource")
all := r.FormValue("all") == "1" all := get("all") == "1"
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64) amount, _ := strconv.ParseFloat(get("amount"), 64)
var earned float64 var earned float64
if amount, earned, err = sell(ctx, s.db, userID, resource, amount, all); err == nil { if amount, earned, err = sell(ctx, s.db, userID, resource, amount, all); err == nil {
message = fmt.Sprintf("Sold %s %s for %s", formatMoney(amount), resource, formatCredits(earned)) message = fmt.Sprintf("Sold %s %s for %s", formatMoney(amount), resource, formatCredits(earned))
@@ -467,15 +497,15 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
} }
case "refine": case "refine":
// Starts a batch: recipe (a refined item name), qty, or all=1 for as many as possible. // Starts a batch: recipe (a refined item name), qty, or all=1 for as many as possible.
recipe := r.FormValue("recipe") recipe := get("recipe")
qty, _ := strconv.Atoi(r.FormValue("qty")) qty, _ := strconv.Atoi(get("qty"))
if qty, err = startRefine(ctx, s.db, userID, recipe, qty, r.FormValue("all") == "1"); err == nil { if qty, err = startRefine(ctx, s.db, userID, recipe, qty, get("all") == "1"); err == nil {
message = fmt.Sprintf("Refining %d %s", qty, recipe) message = fmt.Sprintf("Refining %d %s", qty, recipe)
} }
case "collect": case "collect":
// Collects one finished batch (job), or all of them. // Collects one finished batch (job), or all of them.
var done []RefineryJob var done []RefineryJob
if done, err = collectRefinery(ctx, s.db, userID, r.FormValue("job")); err == nil { if done, err = collectRefinery(ctx, s.db, userID, get("job")); err == nil {
if len(done) == 1 { if len(done) == 1 {
message = fmt.Sprintf("Collected %d %s", done[0].Qty, done[0].Item) message = fmt.Sprintf("Collected %d %s", done[0].Qty, done[0].Item)
} else { } else {
@@ -488,14 +518,14 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
message = fmt.Sprintf("Refinery slot %d online", n) message = fmt.Sprintf("Refinery slot %d online", n)
} }
case "buy-attachment": case "buy-attachment":
item := r.FormValue("item") item := get("item")
if err = buyAttachment(ctx, s.db, userID, item); err == nil { if err = buyAttachment(ctx, s.db, userID, item); err == nil {
message = item + " added to your inventory" message = item + " added to your inventory"
} }
case "equip", "unequip": case "equip", "unequip":
// Puts an attachment (item, by name) on a tool (to), or takes it off. // Puts an attachment (item, by name) on a tool (to), or takes it off.
// A busy tool's idle copy may get it instead; the panel follows that one. // A busy tool's idle copy may get it instead; the panel follows that one.
item, tool := r.FormValue("item"), r.FormValue("to") item, tool := get("item"), get("to")
var used string var used string
if action == "equip" { if action == "equip" {
used, err = equipAttachment(ctx, s.db, userID, item, tool) used, err = equipAttachment(ctx, s.db, userID, item, tool)
@@ -508,51 +538,95 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
view.Drill = followTool(view.Drill, tool, used) view.Drill = followTool(view.Drill, tool, used)
} }
case "hire": case "hire":
name := r.FormValue("advisor") name := get("advisor")
if err = hireAdvisor(ctx, s.db, userID, name); err == nil { if err = hireAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " hired" 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": case "buy-seat":
var n int var n int
if n, err = buyAdvisorSeat(ctx, s.db, userID); err == nil { if n, err = buyAdvisorSeat(ctx, s.db, userID); err == nil {
message = fmt.Sprintf("Advisor seat %d added", n) message = fmt.Sprintf("Advisor seat %d added", n)
} }
case "new-game": case "new-game":
default:
return actionResult{}, errUnknownAction
}
if err != nil && !isGameError(err) {
return actionResult{}, err
}
return actionResult{Action: action, Err: err, Message: message, View: view, BoughtTool: boughtTool,
Upgraded: upgraded, Finished: finished, SaleCredits: saleCredits}, nil
}
// toast is the action's toast, for the state after it.
func (res actionResult) toast(state *State) *Toast {
if res.Err != nil {
return &Toast{Message: res.Err.Error(), Error: true, Kind: "error"}
}
toast := &Toast{Message: res.Message, Kind: res.Action, Finished: strings.Join(res.Finished, ","), Credits: res.SaleCredits}
if t := state.tool(res.Upgraded); t != nil {
toast.Message = state.upgradedText(*t)
}
return toast
}
// handleAction runs a game action and re-renders the screen.
func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID string) {
ctx := r.Context()
res, err := s.runAction(ctx, userID, r.PathValue("action"), r.FormValue)
if errors.Is(err, errUnknownAction) {
http.NotFound(w, r)
return
}
if err != nil {
serverError(w, err)
return
}
if res.Action == "new-game" {
if !s.newPlayers.allow(r) { if !s.newPlayers.allow(r) {
http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests) http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests)
return return
} }
var newID string newID, err := createUser(ctx, s.db, newPlayerName())
if newID, err = createUser(ctx, s.db, newPlayerName()); err != nil { if err != nil {
serverError(w, err) serverError(w, err)
return return
} }
setUserCookie(w, newID) setUserCookie(w, newID)
w.Header().Set("HX-Redirect", "/") w.Header().Set("HX-Redirect", "/")
return return
default:
http.NotFound(w, r)
return
} }
toast := &Toast{Message: message, Kind: action, Finished: strings.Join(finished, ","), Credits: saleCredits} state, v, err := loadView(ctx, s.db, userID, res.View)
if err != nil {
if !isGameError(err) {
serverError(w, err)
return
}
toast = &Toast{Message: err.Error(), Error: true, Kind: "error"}
}
state, err := loadState(ctx, s.db, userID)
if err != nil { if err != nil {
serverError(w, err) serverError(w, err)
return return
} }
if t := state.tool(upgraded); t != nil && !toast.Error { toast := res.toast(state)
toast.Message = state.upgradedText(*t) page := state.page(v)
}
page := state.page(view.resolve(state))
if err := s.loadRanking(ctx, page); err != nil { if err := s.loadRanking(ctx, page); err != nil {
serverError(w, err) serverError(w, err)
return return
@@ -560,10 +634,10 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
page.Banner = s.banner(ctx) page.Banner = s.banner(ctx)
page.OOB = true page.OOB = true
page.Toast = toast page.Toast = toast
if boughtTool != "" { if res.BoughtTool != "" {
page.Modal = state.buySuggestions(boughtTool) page.Modal = state.buySuggestions(res.BoughtTool)
} }
if action == "buy-moon" && !toast.Error { if res.Action == "buy-moon" && !toast.Error {
page.Reveal = page.moonReveal() page.Reveal = page.moonReveal()
} }
s.render(w, "regions", page) s.render(w, "regions", page)
+18
View File
@@ -0,0 +1,18 @@
-- 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;
+4 -1
View File
@@ -237,10 +237,13 @@ create index attachment_user on attachment (user_id);
create unique index attachment_once on attachment (inventory_item_id, item) create unique index attachment_once on attachment (inventory_item_id, item)
where inventory_item_id is not null; where inventory_item_id is not null;
-- Advisors hired (advisors.go), by config name. -- Advisors hired (advisors.go), by config name. Seated ones (seat 1..users.advisor_seats)
-- give their bonus; seat null: hired but not seated.
create table advisor_hire ( create table advisor_hire (
user_id uuid not null references users(id) on delete cascade, user_id uuid not null references users(id) on delete cascade,
advisor text not null, advisor text not null,
hired_at timestamp with time zone not null default now(), hired_at timestamp with time zone not null default now(),
seat smallint,
primary key (user_id, advisor) primary key (user_id, advisor)
); );
create unique index advisor_seat on advisor_hire (user_id, seat) where seat is not null;
+48 -3
View File
@@ -32,8 +32,9 @@ func TestTemplates(t *testing.T) {
unlocked := newPlayerState() // everything open but no moon yet unlocked := newPlayerState() // everything open but no moon yet
unlocked.Onboarding = []string{"all"} unlocked.Onboarding = []string{"all"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} { for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
for _, v := range testViews(s) { for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
v = v.resolve(s) v = v.resolve(s)
p := s.page(v) p := s.page(v)
if v.Section == "leaderboard" { if v.Section == "leaderboard" {
@@ -53,6 +54,7 @@ func TestTemplates(t *testing.T) {
render("regions", p) render("regions", p)
} }
// The buy modal with no moons or only empty wells. // The buy modal with no moons or only empty wells.
s.pagePastInMemory(1)
p := s.page(View{Section: "store"}.resolve(s)) p := s.page(View{Section: "store"}.resolve(s))
p.OOB = true p.OOB = true
p.Modal = &BuyModal{Item: &s.Config.StoreItems[0], NoMoons: true, AllEmpty: true} p.Modal = &BuyModal{Item: &s.Config.StoreItems[0], NoMoons: true, AllEmpty: true}
@@ -166,6 +168,18 @@ func testViews(s *State) []View {
for _, r := range s.Refined { for _, r := range s.Refined {
views = append(views, View{Section: "market", Pick: r.Item}) 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 return views
} }
@@ -265,9 +279,40 @@ func veteranState() *State {
s.Fitted[s.Tools[0].ID] = []string{c.Attachments.Items[0].Name} s.Fitted[s.Tools[0].ID] = []string{c.Attachments.Items[0].Name}
s.Spare[c.Attachments.Items[1].Name] = 2 s.Spare[c.Attachments.Items[1].Name] = 2
} }
if len(c.Advisors.Items) > 0 { // 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}}
s.Advisors = []string{c.Advisors.Items[0].Name} s.Advisors = []string{c.Advisors.Items[0].Name}
s.AdvisorSeats = 1 s.AdvisorSeats = 2
} }
// 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 return s
} }
+140 -1
View File
@@ -17,10 +17,17 @@ userId=:user_id
# A view: s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon, moon=<id>, well=<id>, # A view: s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon, moon=<id>, well=<id>,
# drill=<tool id> (equipment: the tool's attachment panel), # drill=<tool id> (equipment: the tool's attachment panel),
# pick=<resource or refined item> (market: the selected quote), list=past (moons: the Past tab, # pick=<resource or refined item> (market: the selected quote), list=past (moons: the Past tab,
# the only way a mined-out moon shows) # 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)
GET :host/?s=store GET :host/?s=store
:headers :headers
GET :host/?list=past&page=2
:headers
GET :host/?s=advisors&adv=Ada+Voss
:headers
# Buy a moon # Buy a moon
POST :host/act/buy-moon POST :host/act/buy-moon
:headers :headers
@@ -118,6 +125,26 @@ advisor=Mira+Castellanos
POST :host/act/buy-seat POST :host/act/buy-seat
:headers :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) # Start over as a new player (sets a new cookie)
POST :host/act/new-game POST :host/act/new-game
:headers :headers
@@ -125,6 +152,118 @@ POST :host/act/new-game
# Leaderboard (JSON): [{"name", "balance"}], richest first # Leaderboard (JSON): [{"name", "balance"}], richest first
GET :host/api/leaderboard?limit=10 GET :host/api/leaderboard?limit=10
# JSON API for game clients (api.md). The player id goes in X-MM-User (or the mm_user cookie);
# without a known one a player is created and its id comes back in X-MM-User and "userId".
# Game state for a view (same params as GET /): {ok, userId, newPlayer?, state}
GET :host/api/state?moon=<moon id>&well=<well id>
X-MM-User: :user_id
# Run an action (any /act/<action> above, same params, form or JSON body):
# {ok, error?, toast, fx?, state}; a rule violation is a 422 with the state
POST :host/api/act/stake
X-MM-User: :user_id
Content-Type: application/json
{"tool": "<tool id>", "target": "<well id>", "moon": "<moon id>", "well": "<well id>"}
# Claim every finished drill (claim-well, claim-moon, claim-wide, redeploy as for /act/claim)
POST :host/api/act/claim
X-MM-User: :user_id
Content-Type: application/x-www-form-urlencoded
# Sell resources (amount=<n> or all=1)
POST :host/api/act/sell
X-MM-User: :user_id
Content-Type: application/json
{"resource": "Sollux", "all": true}
# Section blocks: s=<section> adds that section's block (equipment with drill=<tool id>,
# store, refinery, advisors, leaderboard, help, new-moon, market with the chart);
# include=<sections>|all adds others
GET :host/api/state?s=equipment&drill=<tool id>&include=store,refinery
X-MM-User: :user_id
GET :host/api/state?s=moons&include=all
X-MM-User: :user_id
# Skip the catalog: catalog=0, or catalog=<state.catalogVersion> (left out while unchanged);
# the same param works on /api/act/<action>
GET :host/api/state?catalog=0
X-MM-User: :user_id
GET :host/api/state?catalog=<catalog version>
X-MM-User: :user_id
POST :host/api/act/claim
X-MM-User: :user_id
Content-Type: application/json
{"catalog": 0, "clientSeq": 2}
# 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)
POST :host/api/act/buy-tool
X-MM-User: :user_id
Content-Type: application/json
{"id": 1, "s": "store", "clientSeq": 1}
POST :host/api/act/equip
X-MM-User: :user_id
Content-Type: application/json
{"item": "Turbo Core", "to": "<tool id>", "s": "equipment", "drill": "<tool id>"}
POST :host/api/act/refine
X-MM-User: :user_id
Content-Type: application/json
{"recipe": "Dusk Alloy", "qty": 3, "s": "refinery"}
POST :host/api/act/hire
X-MM-User: :user_id
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
{"sell-moon": "<moon id>", "moon": "<moon id>"}
# Admin: sync the store items in the database with the game config (admin players only) # Admin: sync the store items in the database with the game config (admin players only)
POST :host/utils/load-config POST :host/utils/load-config
:headers :headers
+91 -17
View File
@@ -138,21 +138,24 @@ func formatCycle(secs int) string {
// ------------------------------------------------------------------ view // ------------------------------------------------------------------ view
// View is what the player is looking at, carried in the URL: // View is what the player is looking at, carried in the URL:
// /?s=<section>&moon=<id>&well=<id>&list=past. // /?s=<section>&moon=<id>&well=<id>&list=past&page=<n>.
type View struct { type View struct {
Section string // moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon Section string // moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon
Moon string Moon string
Past bool // the player opened the moon list's Past tab: a past moon may be on screen 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 Well string
Pick string // the selected resource or refined item (market section) Pick string // the selected resource or refined item (market section)
Drill string // the selected tool (equipment 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} 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 { 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"), return View{Section: get("s"), Moon: get("moon"), Well: get("well"), Pick: get("pick"),
Drill: get("drill"), Past: get("list") == "past"} Drill: get("drill"), Past: get("list") == "past", Page: page, Adv: get("adv")}
} }
// resolve fills in defaults and drops ids that don't belong to the player. // resolve fills in defaults and drops ids that don't belong to the player.
@@ -170,13 +173,9 @@ func (v View) resolve(s *State) View {
if moon != nil && moon.Finished != v.Past { if moon != nil && moon.Finished != v.Past {
moon = nil moon = nil
} }
if moon == nil && v.Past { if moon == nil && v.Past && len(s.PastIDs) > 0 {
// The Past tab: the last moon finished. // The Past tab: the last moon finished (on its page).
for i := range s.Moons { moon = s.moon(s.PastIDs[0])
if s.Moons[i].Finished && (moon == nil || s.Moons[i].FinishedAt.After(moon.FinishedAt)) {
moon = &s.Moons[i]
}
}
} }
if moon == nil { if moon == nil {
v.Past = false v.Past = false
@@ -201,6 +200,10 @@ func (v View) resolve(s *State) View {
v.Drill = "" v.Drill = ""
} }
v.Drill = s.idleCopy(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 return v
} }
@@ -214,6 +217,9 @@ func (v View) URL() string {
} }
if v.Past && v.Section == "moons" { if v.Past && v.Section == "moons" {
q.Set("list", "past") q.Set("list", "past")
if v.Page > 1 {
q.Set("page", strconv.Itoa(v.Page))
}
} }
if v.Well != "" { if v.Well != "" {
q.Set("well", v.Well) q.Set("well", v.Well)
@@ -224,6 +230,9 @@ func (v View) URL() string {
if v.Drill != "" { if v.Drill != "" {
q.Set("drill", v.Drill) q.Set("drill", v.Drill)
} }
if v.Adv != "" {
q.Set("adv", v.Adv)
}
if len(q) == 0 { if len(q) == 0 {
return "/" return "/"
} }
@@ -239,15 +248,20 @@ func (v View) ToMoon(id string) string {
return View{Section: "moons", Moon: id}.URL() return View{Section: "moons", Moon: id}.URL()
} }
// ToPastMoon opens a finished moon from the Past tab. // ToPastMoon opens a finished moon from the Past tab (on the current page of it).
func (v View) ToPastMoon(id string) string { func (v View) ToPastMoon(id string) string {
return View{Section: "moons", Moon: id, Past: true}.URL() return View{Section: "moons", Moon: id, Past: true, Page: v.Page}.URL()
} }
func (v View) ToWell(id string) string { func (v View) ToWell(id string) string {
return View{Section: "moons", Moon: v.Moon, Well: id}.URL() 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. // ToDrill opens a tool's panel in the equipment section.
func (v View) ToDrill(id string) string { func (v View) ToDrill(id string) string {
return View{Section: "equipment", Moon: v.Moon, Drill: id}.URL() return View{Section: "equipment", Moon: v.Moon, Drill: id}.URL()
@@ -351,10 +365,28 @@ func (p *Page) ActiveURL() string {
} }
func (p *Page) PastURL() string { func (p *Page) PastURL() string {
if len(p.Past) == 0 { if p.S.PastTotal == 0 {
return "" return ""
} }
return p.V.ToPastMoon(p.Past[0].ID) 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
} }
type MoonDetail struct { type MoonDetail struct {
@@ -742,7 +774,9 @@ func (s *State) page(v View) *Page {
row.Pending = endsAttr(pending) row.Pending = endsAttr(pending)
p.Ready += row.Ready p.Ready += row.Ready
if m.Finished { if m.Finished {
p.Past = append(p.Past, row) if slices.Contains(s.PastIDs, m.ID) {
p.Past = append(p.Past, row)
}
} else { } else {
p.Moons = append(p.Moons, row) p.Moons = append(p.Moons, row)
} }
@@ -752,7 +786,10 @@ func (s *State) page(v View) *Page {
} }
p.Pending = endsAttr(allPending) p.Pending = endsAttr(allPending)
p.PastView = v.Past p.PastView = v.Past
sort.SliceStable(p.Past, func(i, j int) bool { return p.Past[i].FinishedAt.After(p.Past[j].FinishedAt) }) // 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)
})
switch v.Section { switch v.Section {
case "moons": case "moons":
@@ -897,7 +934,14 @@ func (s *State) page(v View) *Page {
p.Refinery = s.refineryPage() p.Refinery = s.refineryPage()
case "advisors": case "advisors":
p.Advisors = s.advisorsPage() 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
}
}
} }
return p return p
} }
@@ -1350,6 +1394,31 @@ 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) 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, // 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 // oldest first, and how many it leaves out. Key makes the bars' ids unique on the page
// (the morph matches them per drill). // (the morph matches them per drill).
@@ -1387,7 +1456,12 @@ func (r StakeRow) Code() string {
if r.Item == nil { if r.Item == nil {
return "Tool" return "Tool"
} }
f := strings.Fields(r.Item.Name) return toolCode(r.Item.Name)
}
// toolCode is a tool's model code, the last word of its name ("PD-1").
func toolCode(name string) string {
f := strings.Fields(name)
if len(f) == 0 { if len(f) == 0 {
return "Tool" return "Tool"
} }
+82 -1
View File
@@ -1288,6 +1288,74 @@ a.htmx-request::after {
.advisor-card .card-buy.ready { .advisor-card .card-buy.ready {
text-align: center; 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 { .bonus-chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -4570,6 +4638,19 @@ textarea.eco-input {
filter: none; 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 { .past-moon .total td {
color: var(--text); color: var(--text);
font-weight: 600; font-weight: 600;
@@ -4916,7 +4997,7 @@ textarea.eco-input {
gap: 12px; gap: 12px;
} }
.reveal-foot .btn { .reveal-foot .btn:first-child {
margin-left: auto; margin-left: auto;
} }
+5 -1
View File
@@ -168,7 +168,7 @@ function play(kind, toast, prev, at) {
}); });
} }
if (["buy-tool", "upgrade", "buy-moon", "buy-attachment", "equip", "hire", "buy-seat"].includes(kind)) { if (["buy-tool", "upgrade", "buy-moon", "buy-attachment", "equip", "hire", "buy-seat", "seat-advisor", "swap-advisors"].includes(kind)) {
if (at) burst(at.x, at.y, [CYAN, CYAN_HI], 34); if (at) burst(at.x, at.y, [CYAN, CYAN_HI], 34);
// The page may change under a new moon, so sweep its list entry instead. // The page may change under a new moon, so sweep its list entry instead.
const moon = kind === "buy-moon" && document.querySelector("#moonlist .moon[aria-current]"); const moon = kind === "buy-moon" && document.querySelector("#moonlist .moon[aria-current]");
@@ -210,6 +210,10 @@ function play(kind, toast, prev, at) {
equip: "upgrade", equip: "upgrade",
unequip: "toggle", unequip: "toggle",
hire: "confirm", hire: "confirm",
"seat-advisor": "upgrade",
"unseat-advisor": "toggle",
"swap-advisors": "toggle",
"dismiss-advisor": "powerdown",
"buy-seat": "buy", "buy-seat": "buy",
"buy-slot": "buy", "buy-slot": "buy",
upgrade: "upgrade", 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. // Actions keep the current view: send it along unless the button set its own.
document.body.addEventListener("htmx:configRequest", (event) => { document.body.addEventListener("htmx:configRequest", (event) => {
if (event.detail.verb !== "post") return; if (event.detail.verb !== "post") return;
const { s, moon, well, drill, list } = view(); const { s, moon, well, drill, list, page, adv } = view();
const params = event.detail.parameters; const params = event.detail.parameters;
for (const [key, value] of Object.entries({ s, moon, well, drill, list })) { for (const [key, value] of Object.entries({ s, moon, well, drill, list, page, adv })) {
// params is a FormData proxy: `in` never sees its keys, a missing one reads undefined. // params is a FormData proxy: `in` never sees its keys, a missing one reads undefined.
if (params[key] === undefined && value) params[key] = value; if (params[key] === undefined && value) params[key] = value;
} }
+16 -1
View File
@@ -11,7 +11,8 @@
// balance down ("mm:revealed"). // balance down ("mm:revealed").
// //
// A click or key skips ahead: during the charge to the burst, during the card to its end // 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. With prefers-reduced-motion the moon // 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
// swaps in and the card fades. // swaps in and the card fades.
import * as sfx from "./sfx.js"; import * as sfx from "./sfx.js";
@@ -77,6 +78,7 @@ export function playReveal({ scene, seed, settings, source }) {
let stage = "charge"; let stage = "charge";
let card = null; let card = null;
let cardDone = false; let cardDone = false;
let buyAnother = false;
// Narrow screens: the viewport sits below the main area; bring it into view (on phones // 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). // at the top, the card comes up from the bottom).
@@ -325,6 +327,7 @@ export function playReveal({ scene, seed, settings, source }) {
); );
} }
document.dispatchEvent(new CustomEvent("mm:revealed")); document.dispatchEvent(new CustomEvent("mm:revealed"));
if (buyAnother) htmx.ajax("POST", "/act/buy-moon", { source: document.body, swap: "none" });
}; };
sfx.play("close"); sfx.play("close");
layer.animate([{ opacity: 1 }, { opacity: 0 }], { duration: quick ? 150 : 420, easing: "ease-in", fill: "forwards" }).onfinish = done; layer.animate([{ opacity: 1 }, { opacity: 0 }], { duration: quick ? 150 : 420, easing: "ease-in", fill: "forwards" }).onfinish = done;
@@ -362,9 +365,19 @@ export function playReveal({ scene, seed, settings, source }) {
else close(); 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) { function onPointer(event) {
event.preventDefault(); event.preventDefault();
if (event.target.closest?.("[data-reveal-close]") && cardDone) return close(); if (event.target.closest?.("[data-reveal-close]") && cardDone) return close();
const more = event.target.closest?.("[data-reveal-buy]");
if (more && buy(more)) return;
advance(); advance();
} }
function onKey(event) { function onKey(event) {
@@ -372,6 +385,8 @@ export function playReveal({ scene, seed, settings, source }) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (event.key === "Escape" && stage === "card") return close(); 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(); advance();
} }
layer.addEventListener("pointerdown", onPointer); layer.addEventListener("pointerdown", onPointer);
+95 -7
View File
@@ -1,15 +1,17 @@
{{/* The advisors section (advisors.go): the combined bonus, the seats, and every advisor {{/* The advisors section (advisors.go): the combined bonus, the seats and who sits in them,
with a Hire button. */}} 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. */}}
{{define "advisors-section"}}{{with .Advisors}} {{define "advisors-section"}}{{$p := .}}{{with .Advisors}}
<div class="heading"> <div class="heading">
<div class="grow"> <div class="grow">
<h1>Advisors</h1> <h1>Advisors</h1>
<p class="muted measure">Each advisor takes one seat and stays for good.</p> <p class="muted measure">Each seated advisor boosts your whole operation. Click one to move, swap or dismiss them.</p>
</div> </div>
{{if .NextSeatPrice}} {{if .NextSeatPrice}}
<button class="btn btn-dashed" hx-post="/act/buy-seat" <button class="btn btn-dashed" hx-post="/act/buy-seat"
hx-confirm="Add advisor seat {{.NextSeat}} for {{cur .NextSeatPrice}}?" hx-confirm="{{.SeatConfirm}}"
data-confirm-title="New seat" data-confirm-ok="Buy seat" data-confirm-title="New seat" data-confirm-ok="Buy seat"
{{if not .CanBuySeat}}disabled title="{{short $.S.Balance .NextSeatPrice}}"{{end}}>+ Seat · <span class="num accent">{{cur .NextSeatPrice}}</span></button> {{if not .CanBuySeat}}disabled title="{{short $.S.Balance .NextSeatPrice}}"{{end}}>+ Seat · <span class="num accent">{{cur .NextSeatPrice}}</span></button>
{{end}} {{end}}
@@ -20,6 +22,36 @@
<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> <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> </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"> <div class="cards">
{{range .Cards}} {{range .Cards}}
<article class="card advisor-card {{if .Hired}}is-hired{{end}}"> <article class="card advisor-card {{if .Hired}}is-hired{{end}}">
@@ -30,13 +62,69 @@
<p class="mod-effect num">{{.Effect}}</p> <p class="mod-effect num">{{.Effect}}</p>
<p class="card-desc">{{.Description}}</p> <p class="card-desc">{{.Description}}</p>
{{if .Hired}} {{if .Hired}}
<p class="ready card-buy">On your board</p> {{$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>
{{else}} {{else}}
<button class="btn btn-primary card-buy" hx-post="/act/hire" hx-vals="{{vals "advisor" .Name}}" <button class="btn btn-primary card-buy" hx-post="/act/hire" hx-vals="{{vals "advisor" .Name}}"
{{if not $.Advisors.SeatFree}}disabled title="All seats taken{{if $.Advisors.NextSeatPrice}}. Buy another seat first{{end}}"{{else if not .CanAfford}}disabled title="{{short $.S.Balance .Price}}"{{end}}>Hire · <span class="num">{{cur .Price}}</span></button> {{with $p.Advisors.HireWhy . $p.S.Balance}}disabled title="{{.}}"{{end}}>Hire · <span class="num">{{cur .Price}}</span></button>
{{end}} {{end}}
</div> </div>
</article> </article>
{{end}} {{end}}
</div> </div>
{{end}}{{end}} {{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}}
+15 -4
View File
@@ -28,7 +28,7 @@
data-now (the server's clock) lines the countdowns up with the server. */}} data-now (the server's clock) lines the countdowns up with the server. */}}
{{define "view"}} {{define "view"}}
<div id="view" hidden {{template "oob-replace" .}} data-url="{{.V.URL}}" data-s="{{.V.Section}}" <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-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-adv="{{.V.Adv}}" data-page="{{if and .V.Past (gt .V.Page 1)}}{{.V.Page}}{{end}}" data-now="{{.S.Now.UnixMilli}}"></div>
{{end}} {{end}}
{{define "scene"}} {{define "scene"}}
@@ -153,7 +153,7 @@
{{$active := .ActiveURL}} {{$active := .ActiveURL}}
<a href="{{$active}}" hx-get="{{$active}}" {{if not .PastView}}aria-current="page"{{end}}>Active <span class="muted num">{{len .Moons}}</span></a> <a href="{{$active}}" hx-get="{{$active}}" {{if not .PastView}}aria-current="page"{{end}}>Active <span class="muted num">{{len .Moons}}</span></a>
{{with .PastURL}} {{with .PastURL}}
<a href="{{.}}" hx-get="{{.}}" {{if $.PastView}}aria-current="page"{{end}} data-past-tab>Past <span class="muted num">{{len $.Past}}</span></a> <a href="{{.}}" hx-get="{{.}}" {{if $.PastView}}aria-current="page"{{end}} data-past-tab>Past <span class="muted num">{{$.S.PastTotal}}</span></a>
{{else}} {{else}}
<a aria-disabled="true" title="Moons you mine out go here" data-past-tab>Past <span class="muted num">0</span></a> <a aria-disabled="true" title="Moons you mine out go here" data-past-tab>Past <span class="muted num">0</span></a>
{{end}} {{end}}
@@ -172,6 +172,13 @@
<span class="grow"><b>{{.Name}}</b>{{template "rating-chip" .Rating}}<br><span class="muted small num">Mined {{money .TotalMined}}{{with .FinishedOn}} · {{.}}{{end}}</span></span> <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> </a>
{{end}} {{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}} {{else}}
{{range .Moons}} {{range .Moons}}
{{$url := $.V.ToMoon .ID}} {{$url := $.V.ToMoon .ID}}
@@ -585,6 +592,9 @@
{{end}} {{end}}
</ul> </ul>
<div class="reveal-foot"> <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> <button type="button" class="btn btn-primary" data-reveal-close>Start mining</button>
</div> </div>
</section> </section>
@@ -640,6 +650,7 @@
{{define "side"}} {{define "side"}}
<section id="side" class="side{{with .Well}}{{if le .Supply 0.0}} depleted{{end}}{{end}}" {{template "oob" .}}> <section id="side" class="side{{with .Well}}{{if le .Supply 0.0}} depleted{{end}}{{end}}" {{template "oob" .}}>
{{with .Drill}}{{template "drill-panel" .}}{{end}} {{with .Drill}}{{template "drill-panel" .}}{{end}}
{{with .Advisors}}{{with .Panel}}{{template "advisor-panel" $}}{{end}}{{end}}
{{with .Well}} {{with .Well}}
{{$dry := le .Supply 0.0}} {{$dry := le .Supply 0.0}}
<h2>{{template "dot" .Resource}}{{.Resource}} well</h2> <h2>{{template "dot" .Resource}}{{.Resource}} well</h2>
@@ -813,7 +824,7 @@
<span class="muted small num">{{.Effect}} · then {{secs .Secs}}, +{{money .Yield}}</span></span> <span class="muted small num">{{.Effect}} · then {{secs .Secs}}, +{{money .Yield}}</span></span>
<button class="btn btn-sm btn-primary" hx-post="/act/equip" hx-vals="{{vals "item" .Name "to" $.ID}}" {{if not .CanEquip}}disabled title="{{.Why}}"{{end}}>Equip</button> <button class="btn btn-sm btn-primary" hx-post="/act/equip" hx-vals="{{vals "item" .Name "to" $.ID}}" {{if not .CanEquip}}disabled title="{{.Why}}"{{end}}>Equip</button>
<button class="btn btn-sm btn-danger" hx-post="/act/sell-attachment" hx-vals="{{vals "item" .Name}}" <button class="btn btn-sm btn-danger" hx-post="/act/sell-attachment" hx-vals="{{vals "item" .Name}}"
hx-confirm="Sell a spare {{.Name}} for {{cur .Resale}}? You paid {{cur .Price}}." data-confirm-title="Sell {{.Name}}" data-confirm-ok="Sell for {{cur .Resale}}" data-confirm-danger hx-confirm="{{.Confirm}}" data-confirm-title="Sell {{.Name}}" data-confirm-ok="Sell for {{cur .Resale}}" data-confirm-danger
title="Sell one for {{cur .Resale}}">Sell</button> title="Sell one for {{cur .Resale}}">Sell</button>
</div> </div>
{{else}} {{else}}
@@ -825,7 +836,7 @@
<span class="grow">Sells for <span class="num">{{cur .Resale}}</span><br> <span class="grow">Sells for <span class="num">{{cur .Resale}}</span><br>
<span class="muted small">Part of what it and its upgrades cost. Refined parts aren't refunded{{with .Gear}}; its attachments go back to your inventory{{end}}.</span></span> <span class="muted small">Part of what it and its upgrades cost. Refined parts aren't refunded{{with .Gear}}; its attachments go back to your inventory{{end}}.</span></span>
<button class="btn btn-sm btn-danger" hx-post="/act/sell-tool" hx-vals="{{vals "tool" .ID}}" <button class="btn btn-sm btn-danger" hx-post="/act/sell-tool" hx-vals="{{vals "tool" .ID}}"
hx-confirm="Sell this {{.Item.Name}} (T{{.Tier}}) for {{cur .Resale}}?{{with .Gear}} Its attachments go back to your inventory.{{end}}" data-confirm-title="Sell drill" data-confirm-ok="Sell for {{cur .Resale}}" data-confirm-danger hx-confirm="{{.SellConfirm}}" data-confirm-title="Sell drill" data-confirm-ok="Sell for {{cur .Resale}}" data-confirm-danger
{{with .NoSale}}disabled title="{{.}}"{{end}}>Sell</button> {{with .NoSale}}disabled title="{{.}}"{{end}}>Sell</button>
</div> </div>
{{end}} {{end}}
+2 -3
View File
@@ -37,7 +37,7 @@
{{end}} {{end}}
{{if .NextSlotPrice}} {{if .NextSlotPrice}}
<button class="btn btn-dashed refinery-buy-slot" hx-post="/act/buy-slot" <button class="btn btn-dashed refinery-buy-slot" hx-post="/act/buy-slot"
hx-confirm="Add refinery slot {{.NextSlot}} for {{cur .NextSlotPrice}}? Each slot runs one batch at a time." hx-confirm="{{.SlotConfirm}}"
data-confirm-title="New refinery slot" data-confirm-ok="Buy slot" data-confirm-title="New refinery slot" data-confirm-ok="Buy slot"
{{if not .CanBuySlot}}disabled title="{{short $.S.Balance .NextSlotPrice}}"{{end}}> {{if not .CanBuySlot}}disabled title="{{short $.S.Balance .NextSlotPrice}}"{{end}}>
+ Slot {{.NextSlot}} · <span class="num accent">{{cur .NextSlotPrice}}</span></button> + Slot {{.NextSlot}} · <span class="num accent">{{cur .NextSlotPrice}}</span></button>
@@ -47,8 +47,7 @@
<h2 class="refinery-label cap">Recipes</h2> <h2 class="refinery-label cap">Recipes</h2>
<div class="cards"> <div class="cards">
{{range .Recipes}} {{range .Recipes}}
{{$why := ""}} {{$why := $rp.Why .}}
{{if eq $rp.Free 0}}{{$why = "All slots are busy"}}{{else if eq .MaxQty 0}}{{$why = "Not enough inputs"}}{{end}}
<article class="card recipe"> <article class="card recipe">
<div class="card-body"> <div class="card-body">
<div class="recipe-head"> <div class="recipe-head">