Compare commits
15
Commits
85c8219c0d
...
25f315e8dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f315e8dc | ||
|
|
571075c5ff | ||
|
|
fbde1b79ae | ||
|
|
4e7e2d010d | ||
|
|
020d1f7197 | ||
|
|
1fd1059aa7 | ||
|
|
a8bf63ffe4 | ||
|
|
bc34f98e4c | ||
|
|
e6088994ef | ||
|
|
474542abc7 | ||
|
|
c7073e53b9 | ||
|
|
66705d9aba | ||
|
|
0d2a790506 | ||
|
|
960a03a4f6 | ||
|
|
d8a6ae1419 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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/<action>
|
||||||
|
|
||||||
|
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
File diff suppressed because it is too large.
Load diff
+304
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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}}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in new issue
Block a user