Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e267992615 | ||
|
|
fa5fc46cb8 | ||
|
|
7af1c5a821 | ||
|
|
fcec292660 | ||
|
|
bc34f98e4c | ||
|
|
e6088994ef | ||
|
|
474542abc7 | ||
|
|
c7073e53b9 | ||
|
|
66705d9aba | ||
|
|
0d2a790506 | ||
|
|
960a03a4f6 | ||
|
|
d8a6ae1419 |
No files matched your search
@@ -25,6 +25,8 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
|
||||
- Postgres 16, stock image. No functions or extensions in the database.
|
||||
|
||||
## 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`
|
||||
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
|
||||
@@ -38,6 +40,17 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
|
||||
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
|
||||
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
|
||||
supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a
|
||||
name, a placeholder description and resource `affinities`, refinery), embedded.
|
||||
@@ -91,7 +104,7 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
|
||||
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
|
||||
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
|
||||
sections and actions need a gate there.
|
||||
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
|
||||
@@ -126,6 +139,9 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
|
||||
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
|
||||
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
|
||||
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
|
||||
- `scripts/tool-art.mjs`: draws the tool images (`web/static/assets/items`, one per tool and
|
||||
tier: `<image>-tN`, `StoreItem.ImageAt`); rerun it after adding a tool or tier (needs
|
||||
rsvg-convert and ImageMagick). A new tool needs a drawing there.
|
||||
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
|
||||
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–015):
|
||||
changes for existing ones, applied on every `migrate`, so each must be idempotent.
|
||||
@@ -163,8 +179,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
|
||||
the address bar in sync.
|
||||
- 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
|
||||
`hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
|
||||
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
|
||||
`/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.
|
||||
- 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
|
||||
|
||||
@@ -23,7 +23,7 @@ load-config:
|
||||
|
||||
check:
|
||||
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
|
||||
node --no-warnings scripts/check-moon-traits.mjs
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -165,3 +166,21 @@ func (s *State) advisorsPage() *AdvisorsPage {
|
||||
|
||||
// SeatFree reports whether the player has an empty advisor seat.
|
||||
func (p *AdvisorsPage) SeatFree() bool { return p.Hired < p.Seats }
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
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
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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}
|
||||
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},
|
||||
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.ImageAt(t.Tier), Tier: t.Tier, MaxTier: len(item.Upgrades),
|
||||
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
|
||||
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
|
||||
if st := s.StakeForTool(t.ID); st != nil {
|
||||
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)}
|
||||
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()}
|
||||
// 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, err := loadState(ctx, s.db, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, err := s.apiPageFor(ctx, state, v.resolve(state))
|
||||
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, err := loadState(ctx, s.db, userID)
|
||||
if err != nil {
|
||||
apiServerError(w, err)
|
||||
return
|
||||
}
|
||||
toast := res.toast(state)
|
||||
page, err := s.apiPageFor(ctx, state, res.View.resolve(state))
|
||||
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,463 @@
|
||||
# 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), `pick` (market: selected quote), `drill`.
|
||||
`include` (comma-separated sections, or `all`): section blocks to add besides the one for
|
||||
`s` (see [Sections](#sections)); locked ones are skipped. Invalid or locked values fall back
|
||||
like the web (`state.view` is the resolved view). Opening a revealed section with `s=`
|
||||
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 |
|
||||
| `buy-seat` | – | advisor seat |
|
||||
| `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, pick, drill}`, resolved |
|
||||
| `credits` | balance |
|
||||
| `resources`, `refined` | `[{name, amount}]` |
|
||||
| `moonPrice` | price of the next moon |
|
||||
| `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false |
|
||||
| `shine` | revealed sections not opened yet |
|
||||
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
|
||||
| `moons`, `past` | `[{id, name, kind, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18") |
|
||||
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL, the tool's art at its tier (`shovel-t2.png` at T2; tier 0 and tiers without art use the base image); `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
|
||||
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, start, end, durationSecs, ready, take, covers?}` |
|
||||
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
|
||||
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
|
||||
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; `chart` and `spark` only for `s=market` (or `include=market`) |
|
||||
| `banner` | `[{level, text, left, kind, id, end}]` announcements, then live events: `kind` `announcement` or `event`, `id` the announcement's or live event's id (an event's matches `liveEvents[].id`), `end` when it ends; `left` the web's "3h 20m left" at `now` (events only, else `""`) |
|
||||
| `liveEvents` | active live events: `[{id, name, kind, resource, multiplier, start, end, headline}]`, `kind` `price_multiplier`, `cycle_multiplier` or `yield_multiplier` |
|
||||
| `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
|
||||
| `jobs` | once the refinery is open: every batch `[{id, slot, item, qty, start, end, ready}]` (the Refinery tab's badge) |
|
||||
| `bonuses` | active advisor bonuses (the web's bonus chips): `[{kind, mult, capped, label, text}]`, `kind` `speed`, `yield`, `sell`, `refine`; `label` and `text` the chip ("Drill speed", "+10%") |
|
||||
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, slotsPerTool, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkExponent, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the config max, or the biggest owned well if bigger); `boosts` are live event multipliers by resource or `"all"` |
|
||||
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, reach, parts: [{name, amount}], image}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`; a tool's `image` is its tier 0 art, each tier's `image` its art from that tier on); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
|
||||
| `catalogVersion` | a hash of the catalog, for `catalog=` |
|
||||
|
||||
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
|
||||
`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.
|
||||
- `image`: the tool's art at its tier, like `state.tools`.
|
||||
- `next`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}],
|
||||
canAfford, blocked, short, blockedText, shortText}`; `blocked` is `credits` or `parts`
|
||||
(`""` when it can be bought), `short` the credits missing; `blockedText` the button's
|
||||
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, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, bonuses, buySeat, buySeatWhyText,
|
||||
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, canAfford,
|
||||
canHire, why, whyText}]}
|
||||
```
|
||||
|
||||
`buySeat` is the buy-seat dialog (`null` at the cap), `buySeatWhyText` why "+ Seat" is
|
||||
disabled. An advisor's `why`: `hired` ("On your board"), `seats` ("All seats taken. Buy
|
||||
another seat first"), `credits` ("Need ₥X more") or `""`.
|
||||
|
||||
### leaderboard (`s=leaderboard`)
|
||||
|
||||
`{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}]}
|
||||
```
|
||||
|
||||
`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`.
|
||||
|
||||
### 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`, `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`).
|
||||
- well note: "Over-drilled — next claims will come up short", "Fully committed", "X left · Y
|
||||
committed · runs dry after N claims" / "Z free after" (`demand`, `remain`, `dryAfter`).
|
||||
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
|
||||
current drills" (`well.idle[].short`, `here`, `free`).
|
||||
- market: change ("+1.25%"), pressure ("−12%"), boost ("×2"), advisor ("+10%").
|
||||
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
|
||||
|
||||
The web's formats:
|
||||
- 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, "pick": "", "drill": ""},
|
||||
"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": [],
|
||||
"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": "7d983412e77a3dde"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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-t1.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
|
||||
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true,
|
||||
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0},
|
||||
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1,
|
||||
"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": 1, "hired": 0, "maxSeats": 4, "nextSeat": 2, "nextSeatPrice": 10000, "canBuySeat": true, "seatFree": true,
|
||||
"bonuses": [],
|
||||
"advisors": [{"name": "Ada Voss", "title": "Shift Foreman", "image": "/static/assets/advisors/ada-voss.png",
|
||||
"description": "Runs the drill crews in tight shifts. No rig sits idle on her watch.", "price": 8000,
|
||||
"effect": {"speed": 1.1, "yield": 1, "sell": 1, "refine": 1}, "hired": false, "canAfford": true,
|
||||
"canHire": true}, "…"]}
|
||||
```
|
||||
|
||||
`s=leaderboard`: `{"players": 2, "top": [{"rank": 1, "name": "…", "credits": 73579.5, "me": true}, "…"], "you": null, "me": {"rank": 1, "credits": 73579.5}}`
|
||||
|
||||
`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}, "…"]}
|
||||
```
|
||||
|
||||
`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"]}, "…"]}
|
||||
```
|
||||
@@ -0,0 +1,977 @@
|
||||
package main
|
||||
|
||||
// JSON API, the sections beyond the core loop (see api.go, api.md): equipment and the drill
|
||||
// panel, store, refinery, advisors, leaderboard, help, the new-moon teaser and reveal, the
|
||||
// market chart, the moon sale, bonuses, the catalog of assets and the rules a client needs
|
||||
// to predict results. Every block maps a view model the templates use; no game rules here.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Image URLs, as the templates build them.
|
||||
const (
|
||||
itemImages = "/static/assets/items/"
|
||||
attachmentImages = "/static/assets/attachments/"
|
||||
advisorImages = "/static/assets/advisors/"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------ always present
|
||||
|
||||
// APICatalog is the static game data: every tool, attachment, advisor, resource and
|
||||
// refined item with its image and color.
|
||||
type APICatalog struct {
|
||||
Tools []APICatalogTool `json:"tools"`
|
||||
Attachments []APICatalogAttachment `json:"attachments"`
|
||||
Advisors []APICatalogAdvisor `json:"advisors"`
|
||||
Resources []APICatalogResource `json:"resources"`
|
||||
Refined []APICatalogRefined `json:"refined"`
|
||||
Reach []APIReachText `json:"reach"` // the web's wording of a reach, 1 up to the whole moon
|
||||
}
|
||||
|
||||
// APIReachText is how the web words a tool's reach (wells one cycle mines): label on cards
|
||||
// ("Mines 3 wells", "Strips the whole moon"; "" for one), short on deploy cards ("3 wells",
|
||||
// "whole moon"; "" for one), wells in the tier ladder ("1 well", "whole moon"). A reach at or
|
||||
// above the last entry's is the whole moon.
|
||||
type APIReachText struct {
|
||||
Reach int `json:"reach"`
|
||||
Label string `json:"label"`
|
||||
Short string `json:"short"`
|
||||
Wells string `json:"wells"`
|
||||
}
|
||||
|
||||
type APICatalogTool struct {
|
||||
StoreItemID int `json:"storeItemId"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"` // e.g. "PD-1"
|
||||
Image string `json:"image"`
|
||||
Description string `json:"description"`
|
||||
Price float64 `json:"price"`
|
||||
Yield float64 `json:"yield"` // base, tier 0
|
||||
CycleSecs int `json:"cycleSecs"` // base
|
||||
Reach int `json:"reach"` // tier 0
|
||||
Tiers []APITierUp `json:"tiers"` // upgrades, tier 1 on
|
||||
}
|
||||
|
||||
type APITierUp struct {
|
||||
Tier int `json:"tier"`
|
||||
Price float64 `json:"price"`
|
||||
ClaimBoost float64 `json:"claimBoost"`
|
||||
Reach int `json:"reach"` // wells per cycle from this tier on
|
||||
Parts []APIAmount `json:"parts"` // refined items it uses up
|
||||
Image string `json:"image"` // the tool's image at this tier
|
||||
}
|
||||
|
||||
type APIEffect struct {
|
||||
Speed float64 `json:"speed"` // multipliers, 1 = none
|
||||
Yield float64 `json:"yield"`
|
||||
Sell float64 `json:"sell,omitempty"`
|
||||
Refine float64 `json:"refine,omitempty"`
|
||||
}
|
||||
|
||||
type APICatalogAttachment struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
Description string `json:"description"`
|
||||
Price float64 `json:"price"`
|
||||
Resale float64 `json:"resale"`
|
||||
Effect APIEffect `json:"effect"`
|
||||
EffectText string `json:"effectText"` // e.g. "+20% speed"
|
||||
}
|
||||
|
||||
type APICatalogAdvisor struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Image string `json:"image"`
|
||||
Description string `json:"description"`
|
||||
Price float64 `json:"price"`
|
||||
Effect APIEffect `json:"effect"`
|
||||
EffectText string `json:"effectText"` // e.g. "+8% sell prices"
|
||||
}
|
||||
|
||||
type APICatalogResource struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Price float64 `json:"price"` // base price
|
||||
Symbol string `json:"symbol"`
|
||||
}
|
||||
|
||||
type APICatalogRefined struct {
|
||||
Name string `json:"name"`
|
||||
Tier int `json:"tier"`
|
||||
Color string `json:"color"`
|
||||
Price float64 `json:"price"` // base price
|
||||
Seconds int `json:"seconds"` // per unit, batch of one
|
||||
Inputs []APIAmount `json:"inputs"` // per unit
|
||||
Description string `json:"description"`
|
||||
Symbol string `json:"symbol"`
|
||||
}
|
||||
|
||||
func colorOf(name string) string {
|
||||
if c, ok := resourceColors[name]; ok {
|
||||
return c
|
||||
}
|
||||
return refinedColor(name)
|
||||
}
|
||||
|
||||
func effectOf(speed, yield, sell, refine float64) APIEffect {
|
||||
e := APIEffect{Speed: mult(speed), Yield: mult(yield)}
|
||||
if sell != 0 {
|
||||
e.Sell = sell
|
||||
}
|
||||
if refine != 0 {
|
||||
e.Refine = refine
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func apiNeed(need map[string]float64) []APIAmount {
|
||||
out := []APIAmount{}
|
||||
for _, name := range sortedKeys(need) {
|
||||
out = append(out, APIAmount{Name: name, Amount: need[name]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiCatalog(s *State) *APICatalog {
|
||||
c := cfg()
|
||||
cat := &APICatalog{Tools: []APICatalogTool{}, Attachments: []APICatalogAttachment{}, Advisors: []APICatalogAdvisor{},
|
||||
Resources: []APICatalogResource{}, Refined: []APICatalogRefined{}, Reach: []APIReachText{}}
|
||||
for i := range s.Config.StoreItems {
|
||||
item := &s.Config.StoreItems[i]
|
||||
t := APICatalogTool{StoreItemID: item.ID, Name: item.Name, Code: toolCode(item.Name), Image: itemImages + item.Image,
|
||||
Description: item.Description, Price: item.Price, Yield: item.ClaimAmount, CycleSecs: item.CompletionTimeInSecs,
|
||||
Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}}
|
||||
for _, u := range item.Upgrades {
|
||||
t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost,
|
||||
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier)),
|
||||
Image: itemImages + item.ImageAt(u.Tier)})
|
||||
}
|
||||
cat.Tools = append(cat.Tools, t)
|
||||
}
|
||||
for i := range c.Attachments.Items {
|
||||
a := &c.Attachments.Items[i]
|
||||
cat.Attachments = append(cat.Attachments, APICatalogAttachment{Name: a.Name, Image: attachmentImages + a.Image,
|
||||
Description: a.Description, Price: a.Price, Resale: a.Resale(), Effect: effectOf(a.Speed, a.Yield, 0, 0),
|
||||
EffectText: a.Effect()})
|
||||
}
|
||||
for i := range c.Advisors.Items {
|
||||
a := &c.Advisors.Items[i]
|
||||
cat.Advisors = append(cat.Advisors, APICatalogAdvisor{Name: a.Name, Title: a.Title, Image: advisorImages + a.Image,
|
||||
Description: a.Description, Price: a.Price, Effect: effectOf(a.Speed, a.Yield, mult(a.Sell), mult(a.Refine)),
|
||||
EffectText: a.Effect()})
|
||||
}
|
||||
for _, name := range sortedKeys(c.ResourcePrices) {
|
||||
cat.Resources = append(cat.Resources, APICatalogResource{Name: name, Color: colorOf(name),
|
||||
Price: c.ResourcePrices[name], Symbol: marketSymbol(name)})
|
||||
}
|
||||
for i := range c.Refinery.Items {
|
||||
r := &c.Refinery.Items[i]
|
||||
cat.Refined = append(cat.Refined, APICatalogRefined{Name: r.Name, Tier: r.Tier, Color: r.Color, Price: r.Price,
|
||||
Seconds: r.Seconds, Inputs: apiNeed(r.Inputs), Description: r.Description, Symbol: marketSymbol(r.Name)})
|
||||
}
|
||||
for reach := 1; reach <= max(1, len(c.ResourcePrices)); reach++ {
|
||||
short := ""
|
||||
if reach > 1 {
|
||||
short = wellsText(reach)
|
||||
}
|
||||
cat.Reach = append(cat.Reach, APIReachText{Reach: reach, Label: reachLabel(reach), Short: short, Wells: wellsText(reach)})
|
||||
}
|
||||
return cat
|
||||
}
|
||||
|
||||
// version is a short hash of the catalog (catalogVersion): it changes when the config does.
|
||||
func (c *APICatalog) version() string {
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// APIConfirm is a confirm dialog the web shows before an action (hx-confirm): its title,
|
||||
// question and OK button; danger marks a destructive one.
|
||||
type APIConfirm struct {
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
OK string `json:"ok"`
|
||||
Danger bool `json:"danger"`
|
||||
}
|
||||
|
||||
// APIRules are the numbers a client needs to predict results (api.md, Prediction).
|
||||
type APIRules struct {
|
||||
WellSupplyMax float64 `json:"wellSupplyMax"` // config max: a full reveal bar is this × the well scale
|
||||
MaxSupply float64 `json:"maxSupply"` // the scale of the web's supply bars
|
||||
SlotsPerTool int `json:"slotsPerTool"`
|
||||
WellsMin int `json:"wellsMin"`
|
||||
WellsMax int `json:"wellsMax"`
|
||||
Pressure APIPressure `json:"pressure"`
|
||||
Refinery APIRefineryRules `json:"refinery"`
|
||||
MoonResale MoonResale `json:"moonResale"`
|
||||
EquipmentResale float64 `json:"equipmentResale"` // factor of the credit price
|
||||
BonusCaps BonusCaps `json:"bonusCaps"`
|
||||
Bonus APIEffect `json:"bonus"` // the advisors' bonus now, capped (tools add attachments)
|
||||
Boosts map[string]APIMap `json:"boosts"` // live event multipliers: price, cycle, yield, by resource or "all"
|
||||
}
|
||||
|
||||
type APIMap = map[string]float64
|
||||
|
||||
type APIPressure struct {
|
||||
Floor float64 `json:"floor"`
|
||||
Scale float64 `json:"scale"`
|
||||
HalfLifeSecs float64 `json:"halfLifeSecs"`
|
||||
Cap float64 `json:"cap"` // stored pressure is capped at this
|
||||
}
|
||||
|
||||
type APIRefineryRules struct {
|
||||
BulkExponent float64 `json:"bulkExponent"`
|
||||
MaxBatch int `json:"maxBatch"`
|
||||
Speed float64 `json:"speed"` // advisors' refine bonus × the cycle event for "all"
|
||||
}
|
||||
|
||||
func apiRules(p *Page) *APIRules {
|
||||
s, c := p.S, cfg()
|
||||
b := s.Bonus()
|
||||
lo, hi := wellCountRange()
|
||||
r := &APIRules{WellSupplyMax: c.WellSupplyMax, MaxSupply: p.MaxSupply, SlotsPerTool: c.Attachments.SlotsPerTool,
|
||||
WellsMin: lo, WellsMax: hi,
|
||||
Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale,
|
||||
HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale},
|
||||
Refinery: APIRefineryRules{BulkExponent: c.Refinery.BulkExponent, MaxBatch: c.Refinery.MaxBatch,
|
||||
Speed: b.Refine * s.CycleBoosts.For("all")},
|
||||
MoonResale: c.MoonResale, EquipmentResale: c.EquipmentResale.Factor, BonusCaps: c.BonusCaps,
|
||||
Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield, Sell: b.Sell, Refine: b.Refine},
|
||||
Boosts: map[string]APIMap{"price": boostMap(s.PriceBoosts), "cycle": boostMap(s.CycleBoosts), "yield": boostMap(s.YieldBoosts)}}
|
||||
return r
|
||||
}
|
||||
|
||||
func boostMap(b PriceBoosts) APIMap {
|
||||
m := APIMap{}
|
||||
for k, v := range b {
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// APIBonusRow is an active advisor bonus (the web's bonus chips).
|
||||
type APIBonusRow struct {
|
||||
Kind string `json:"kind"` // speed, yield, sell, refine
|
||||
Mult float64 `json:"mult"`
|
||||
Capped bool `json:"capped"`
|
||||
Label string `json:"label"` // the chip's text: "Drill speed", "+10%"
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func apiBonusRows(b Bonus) []APIBonusRow {
|
||||
caps := cfg().BonusCaps
|
||||
out := []APIBonusRow{}
|
||||
for _, r := range []struct {
|
||||
kind, label string
|
||||
v, cap float64
|
||||
}{{"speed", "Drill speed", b.Speed, caps.Speed}, {"yield", "Yield", b.Yield, caps.Yield},
|
||||
{"sell", "Sell prices", b.Sell, caps.Sell}, {"refine", "Refinery speed", b.Refine, caps.Refine}} {
|
||||
if r.v > 1 {
|
||||
out = append(out, APIBonusRow{Kind: r.kind, Mult: r.v, Capped: r.v >= r.cap, Label: r.label, Text: pctText(r.v)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// APIJob is a refinery batch (always in the state once the refinery is open: the nav badge).
|
||||
type APIJob struct {
|
||||
ID string `json:"id"`
|
||||
Slot int `json:"slot"`
|
||||
Item string `json:"item"`
|
||||
Qty int `json:"qty"`
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
Ready bool `json:"ready"`
|
||||
Progress float64 `json:"progress,omitempty"` // 0..1, refinery section only
|
||||
}
|
||||
|
||||
func apiJob(j RefineryJob, now int64) APIJob {
|
||||
return APIJob{ID: j.ID, Slot: j.Slot, Item: j.Item, Qty: j.Qty, Start: j.StartedAt.UnixMilli(),
|
||||
End: j.EndsAt.UnixMilli(), Ready: j.EndsAt.UnixMilli() <= now}
|
||||
}
|
||||
|
||||
// APILiveEvent is an active live event (the banner shows its headline).
|
||||
type APILiveEvent struct {
|
||||
ID int `json:"id"` // banner items of kind event carry it
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // price_multiplier, cycle_multiplier, yield_multiplier
|
||||
Resource string `json:"resource"`
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
Headline string `json:"headline"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ equipment
|
||||
|
||||
type APIEquipment struct {
|
||||
Tools []APIToolRow `json:"tools"` // copies of a tool (same type, tier, attachments) grouped
|
||||
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
|
||||
}
|
||||
|
||||
type APIToolRow struct {
|
||||
ID string `json:"id"` // the copy shown (selected, else an idle one, else the first to finish)
|
||||
StoreItemID int `json:"storeItemId"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Image string `json:"image"`
|
||||
Tier int `json:"tier"`
|
||||
MaxTier int `json:"maxTier"`
|
||||
Count int `json:"count"`
|
||||
Idle int `json:"idle"`
|
||||
Reach int `json:"reach"`
|
||||
CycleSecs int `json:"cycleSecs"`
|
||||
Yield float64 `json:"yield"`
|
||||
Attachments []string `json:"attachments"`
|
||||
Slots int `json:"slots"`
|
||||
FreeSlots int `json:"freeSlots"`
|
||||
Selected bool `json:"selected"`
|
||||
Stake *APIStake `json:"stake"` // the shown copy's drill
|
||||
Next *APINextTier `json:"next"` // null at the max tier
|
||||
Preview *APIPreview `json:"preview"`
|
||||
Ladder []APITierRow `json:"ladder"`
|
||||
Resale float64 `json:"resale"`
|
||||
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
|
||||
NoSaleText string `json:"noSaleText"`
|
||||
Bonus APIEffect `json:"bonus"` // advisors and its attachments, capped
|
||||
Gear []APIGearDetail `json:"gear"`
|
||||
}
|
||||
|
||||
type APIGearDetail struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
Effect APIEffect `json:"effect"`
|
||||
EffectText string `json:"effectText"`
|
||||
}
|
||||
|
||||
type APINextTier struct {
|
||||
Tier int `json:"tier"`
|
||||
Price float64 `json:"price"`
|
||||
Parts []APIPart `json:"parts"`
|
||||
CanAfford bool `json:"canAfford"` // credits and parts
|
||||
Blocked string `json:"blocked"` // credits, parts; "" when it can be bought
|
||||
Short float64 `json:"short"` // credits missing
|
||||
// The web's texts: why the upgrade button is disabled (its tooltip) and the line under it
|
||||
// when credits are short ("Need ₥X more").
|
||||
BlockedText string `json:"blockedText"`
|
||||
ShortText string `json:"shortText"`
|
||||
}
|
||||
|
||||
type APIPart struct {
|
||||
Name string `json:"name"`
|
||||
Need float64 `json:"need"`
|
||||
Have float64 `json:"have"`
|
||||
}
|
||||
|
||||
type APIPreview struct {
|
||||
Tier int `json:"tier"`
|
||||
YieldFrom float64 `json:"yieldFrom"`
|
||||
YieldTo float64 `json:"yieldTo"`
|
||||
SecsFrom int `json:"secsFrom"`
|
||||
SecsTo int `json:"secsTo"`
|
||||
ReachFrom int `json:"reachFrom"`
|
||||
ReachTo int `json:"reachTo"`
|
||||
RateFrom float64 `json:"rateFrom"` // credits per hour
|
||||
RateTo float64 `json:"rateTo"`
|
||||
Cost float64 `json:"cost"` // price plus the parts at sell value
|
||||
PaybackSecs *float64 `json:"paybackSecs"` // null if it never pays back
|
||||
}
|
||||
|
||||
type APITierRow struct {
|
||||
Tier int `json:"tier"`
|
||||
CycleSecs int `json:"cycleSecs"`
|
||||
Yield float64 `json:"yield"`
|
||||
Reach int `json:"reach"`
|
||||
Rate float64 `json:"rate"` // credits per hour
|
||||
Current bool `json:"current"`
|
||||
Next bool `json:"next"`
|
||||
}
|
||||
|
||||
type APIDrillPanel struct {
|
||||
ToolID string `json:"toolId"`
|
||||
Slots []APISlot `json:"slots"`
|
||||
Spares []APISpare `json:"spares"`
|
||||
Mining bool `json:"mining"`
|
||||
Resale float64 `json:"resale"`
|
||||
NoSale string `json:"noSale"`
|
||||
NoSaleText string `json:"noSaleText"`
|
||||
Confirm APIConfirm `json:"confirm"` // before sell-tool
|
||||
IdleSame int `json:"idleSame"` // while it mines: idle tools of its type
|
||||
IdleBest string `json:"idleBest"` // the one pickTool would deploy
|
||||
DeployTo *APISuggestion `json:"deployTo"` // its best well
|
||||
Ladder []APITierRow `json:"ladder"`
|
||||
Gear []APIGearDetail `json:"gear"`
|
||||
}
|
||||
|
||||
type APISlot struct {
|
||||
N int `json:"n"`
|
||||
Attachment string `json:"attachment"` // "" empty; unequip with item=<name>&to=<toolId>
|
||||
}
|
||||
|
||||
type APISpare struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
CycleSecs int `json:"cycleSecs"` // the drill's stats with it equipped
|
||||
Yield float64 `json:"yield"`
|
||||
CanEquip bool `json:"canEquip"`
|
||||
Why string `json:"why"` // fitted, full
|
||||
WhyText string `json:"whyText"`
|
||||
Price float64 `json:"price"`
|
||||
Resale float64 `json:"resale"`
|
||||
Confirm APIConfirm `json:"confirm"` // before sell-attachment
|
||||
}
|
||||
|
||||
func apiTierRows(rows []TierRow) []APITierRow {
|
||||
out := []APITierRow{}
|
||||
for _, r := range rows {
|
||||
out = append(out, APITierRow{Tier: r.Tier, CycleSecs: r.Secs, Yield: r.Yield, Reach: r.Reach, Rate: r.Rate,
|
||||
Current: r.Current, Next: r.Next})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiGearDetail(gear []*Attachment) []APIGearDetail {
|
||||
out := []APIGearDetail{}
|
||||
for _, a := range gear {
|
||||
out = append(out, APIGearDetail{Name: a.Name, Image: attachmentImages + a.Image, Effect: effectOf(a.Speed, a.Yield, 0, 0),
|
||||
EffectText: a.Effect()})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiEquipment(p *Page) *APIEquipment {
|
||||
s := p.S
|
||||
e := &APIEquipment{Tools: []APIToolRow{}}
|
||||
for _, row := range p.Equipment {
|
||||
b := s.toolBonus(row.ID)
|
||||
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
|
||||
Image: itemImages + row.Item.ImageAt(row.Tier), Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle,
|
||||
Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield, Attachments: apiGear(row.Gear),
|
||||
Slots: len(row.Gear) + row.FreeSlots, FreeSlots: row.FreeSlots, Selected: row.Selected,
|
||||
Ladder: apiTierRows(row.Ladder), Resale: row.Item.ToolResale(row.Tier), NoSale: s.noSale(row),
|
||||
NoSaleText: noSaleText[s.noSale(row)],
|
||||
Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}, Gear: apiGearDetail(row.Gear)}
|
||||
if row.Stake != nil {
|
||||
st := apiStake(*row.Stake, false)
|
||||
t.Stake = &st
|
||||
}
|
||||
if n := row.Next; n != nil {
|
||||
next := &APINextTier{Tier: n.Tier, Price: n.Price, Parts: []APIPart{}, CanAfford: row.CanAfford,
|
||||
Short: max(0, n.Price-s.Balance), BlockedText: row.Missing, ShortText: row.Short}
|
||||
for _, part := range row.Parts {
|
||||
next.Parts = append(next.Parts, APIPart{Name: part.Name, Need: part.Need, Have: part.Have})
|
||||
if !part.OK() && next.Blocked == "" && next.Short == 0 {
|
||||
next.Blocked = "parts"
|
||||
}
|
||||
}
|
||||
if next.Short > 0 {
|
||||
next.Blocked = "credits"
|
||||
}
|
||||
t.Next = next
|
||||
}
|
||||
if u := row.Preview; u != nil {
|
||||
pv := &APIPreview{Tier: u.Tier, YieldFrom: u.YieldFrom, YieldTo: u.YieldTo, SecsFrom: u.SecsFrom, SecsTo: u.SecsTo,
|
||||
ReachFrom: max(1, u.ReachFrom), ReachTo: max(1, u.ReachTo), RateFrom: u.RateFrom, RateTo: u.RateTo, Cost: u.Cost}
|
||||
if gain := u.RateGain(); gain > 0 {
|
||||
secs := u.Cost / gain * 3600
|
||||
pv.PaybackSecs = &secs
|
||||
}
|
||||
t.Preview = pv
|
||||
}
|
||||
e.Tools = append(e.Tools, t)
|
||||
}
|
||||
if d := p.Drill; d != nil {
|
||||
dp := &APIDrillPanel{ToolID: d.ID, Slots: []APISlot{}, Spares: []APISpare{}, Mining: d.Mining, Resale: d.Resale,
|
||||
NoSale: s.noSale(d.ToolRow), NoSaleText: d.NoSale, IdleSame: d.IdleSame, Ladder: apiTierRows(d.Ladder),
|
||||
Gear: apiGearDetail(d.Gear),
|
||||
Confirm: APIConfirm{Title: "Sell drill", Text: d.SellConfirm(), OK: "Sell for " + formatCredits(d.Resale), Danger: true}}
|
||||
for _, slot := range d.Slots {
|
||||
a := APISlot{N: slot.N}
|
||||
if slot.Gear != nil {
|
||||
a.Attachment = slot.Gear.Name
|
||||
}
|
||||
dp.Slots = append(dp.Slots, a)
|
||||
}
|
||||
for _, sg := range d.Spares {
|
||||
sp := APISpare{Name: sg.Name, Count: sg.Count, CycleSecs: sg.Secs, Yield: sg.Yield, CanEquip: sg.CanEquip,
|
||||
WhyText: sg.Why, Price: sg.Price, Resale: sg.Resale(),
|
||||
Confirm: APIConfirm{Title: "Sell " + sg.Name, Text: sg.Confirm(), OK: "Sell for " + formatCredits(sg.Resale()), Danger: true}}
|
||||
switch {
|
||||
case sg.CanEquip:
|
||||
case strings.HasPrefix(sg.Why, "Already"):
|
||||
sp.Why = "fitted"
|
||||
default:
|
||||
sp.Why = "full"
|
||||
}
|
||||
dp.Spares = append(dp.Spares, sp)
|
||||
}
|
||||
if d.IdleBest != nil {
|
||||
dp.IdleBest = d.IdleBest.ID
|
||||
}
|
||||
if d.DeployTo != nil {
|
||||
sg := apiSuggestion(*d.DeployTo)
|
||||
dp.DeployTo = &sg
|
||||
}
|
||||
e.Drill = dp
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ buy-tool suggestions
|
||||
|
||||
// APIBuyModal is what the web's buy modal shows after buy-tool: wells to deploy it on.
|
||||
type APIBuyModal struct {
|
||||
ToolID string `json:"toolId"`
|
||||
StoreItemID int `json:"storeItemId"`
|
||||
CycleSecs int `json:"cycleSecs"`
|
||||
Yield float64 `json:"yield"`
|
||||
Reach int `json:"reach"`
|
||||
NoMoons bool `json:"noMoons"`
|
||||
AllEmpty bool `json:"allEmpty"`
|
||||
Suggestions []APISuggestion `json:"suggestions"` // best first, at most 3
|
||||
}
|
||||
|
||||
type APISuggestion struct {
|
||||
MoonID string `json:"moonId"`
|
||||
MoonName string `json:"moonName"`
|
||||
WellID string `json:"wellId"` // stake with tool=<id>&target=<wellId>
|
||||
Resource string `json:"resource"`
|
||||
Supply float64 `json:"supply"`
|
||||
Credits float64 `json:"credits"` // per cycle at the market price now
|
||||
PerMin float64 `json:"perMin"`
|
||||
Idle bool `json:"idle"` // no drill on it yet
|
||||
Reason string `json:"reason"` // best-value, most-supply, no-drill, shared
|
||||
ReasonText string `json:"reasonText"`
|
||||
}
|
||||
|
||||
var suggestionReasons = map[string]string{"Highest value": "best-value", "Most supply left": "most-supply",
|
||||
"No drill here yet": "no-drill", "Shares a well": "shared"}
|
||||
|
||||
func apiSuggestion(sg Suggestion) APISuggestion {
|
||||
return APISuggestion{MoonID: sg.MoonID, MoonName: sg.MoonName, WellID: sg.ID, Resource: sg.Resource, Supply: sg.Supply,
|
||||
Credits: sg.Credits, PerMin: sg.PerMin, Idle: sg.Idle, Reason: suggestionReasons[sg.Reason], ReasonText: sg.Reason}
|
||||
}
|
||||
|
||||
func apiBuyModal(m *BuyModal) *APIBuyModal {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
a := &APIBuyModal{ToolID: m.ToolID, StoreItemID: m.Item.ID, CycleSecs: m.Secs, Yield: m.Yield, Reach: max(1, m.Reach),
|
||||
NoMoons: m.NoMoons, AllEmpty: m.AllEmpty, Suggestions: []APISuggestion{}}
|
||||
for _, sg := range m.Suggestions {
|
||||
a.Suggestions = append(a.Suggestions, apiSuggestion(sg))
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ store
|
||||
|
||||
type APIStore struct {
|
||||
Tools []APIStoreTool `json:"tools"`
|
||||
Attachments []APIStoreGear `json:"attachments"`
|
||||
}
|
||||
|
||||
type APIStoreTool struct {
|
||||
StoreItemID int `json:"storeItemId"` // buy-tool id=<storeItemId>
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Image string `json:"image"`
|
||||
Description string `json:"description"`
|
||||
Price float64 `json:"price"`
|
||||
CanAfford bool `json:"canAfford"`
|
||||
Owned int `json:"owned"`
|
||||
Yield float64 `json:"yield"` // base, as the card shows
|
||||
CycleSecs int `json:"cycleSecs"` // base
|
||||
Reach int `json:"reach"`
|
||||
Rate float64 `json:"rate"` // credits per hour it would earn, with advisors
|
||||
BestRate float64 `json:"bestRate"` // the same for the player's best tool
|
||||
WhyText string `json:"whyText"` // why Buy is disabled ("Need ₥X more"), "" if it isn't
|
||||
}
|
||||
|
||||
type APIStoreGear struct {
|
||||
Name string `json:"name"` // buy-attachment item=<name>
|
||||
Image string `json:"image"`
|
||||
Description string `json:"description"`
|
||||
Effect APIEffect `json:"effect"`
|
||||
EffectText string `json:"effectText"`
|
||||
Price float64 `json:"price"`
|
||||
CanAfford bool `json:"canAfford"`
|
||||
Spare int `json:"spare"`
|
||||
Equipped int `json:"equipped"`
|
||||
Resale float64 `json:"resale"`
|
||||
WhyText string `json:"whyText"` // why Buy is disabled, "" if it isn't
|
||||
}
|
||||
|
||||
// whyShort is the web's tooltip on a Buy button the player can't afford, "" when they can.
|
||||
func whyShort(canAfford bool, balance, price float64) string {
|
||||
if canAfford {
|
||||
return ""
|
||||
}
|
||||
return shortBy(price - balance)
|
||||
}
|
||||
|
||||
func apiStore(p *Page) *APIStore {
|
||||
bal := p.S.Balance
|
||||
st := &APIStore{Tools: []APIStoreTool{}, Attachments: []APIStoreGear{}}
|
||||
for _, r := range p.Store {
|
||||
st.Tools = append(st.Tools, APIStoreTool{StoreItemID: r.Item.ID, Name: r.Item.Name, Code: toolCode(r.Item.Name),
|
||||
Image: itemImages + r.Item.Image, Description: r.Item.Description, Price: r.Item.Price, CanAfford: r.CanAfford,
|
||||
Owned: r.Owned, Yield: r.Item.ClaimAmount, CycleSecs: r.Item.CompletionTimeInSecs,
|
||||
Reach: max(1, r.Item.ToolReach(0)), Rate: r.Rate, BestRate: r.BestRate, WhyText: whyShort(r.CanAfford, bal, r.Item.Price)})
|
||||
}
|
||||
for _, g := range p.Gear {
|
||||
st.Attachments = append(st.Attachments, APIStoreGear{Name: g.Name, Image: attachmentImages + g.Image,
|
||||
Description: g.Description, Effect: effectOf(g.Speed, g.Yield, 0, 0), EffectText: g.Attachment.Effect(), Price: g.Price,
|
||||
CanAfford: g.CanAfford, Spare: g.Spare, Equipped: g.Equipped, Resale: g.Resale(), WhyText: whyShort(g.CanAfford, bal, g.Price)})
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ refinery
|
||||
|
||||
type APIRefinery struct {
|
||||
Slots []APIRefinerySlot `json:"slots"`
|
||||
SlotCount int `json:"slotCount"`
|
||||
MaxSlots int `json:"maxSlots"`
|
||||
NextSlot int `json:"nextSlot"`
|
||||
NextSlotPrice float64 `json:"nextSlotPrice"` // 0 at the cap
|
||||
CanBuySlot bool `json:"canBuySlot"`
|
||||
Free int `json:"free"`
|
||||
Ready int `json:"ready"`
|
||||
MaxBatch int `json:"maxBatch"`
|
||||
Recipes []APIRecipe `json:"recipes"`
|
||||
Stock []APIAmount `json:"stock"` // refined items owned
|
||||
BuySlot *APIConfirm `json:"buySlot"` // before buy-slot; null at the cap
|
||||
BuySlotWhy string `json:"buySlotWhyText"` // why "+ Slot" is disabled, "" if it isn't
|
||||
}
|
||||
|
||||
type APIRefinerySlot struct {
|
||||
N int `json:"n"`
|
||||
Job *APIJob `json:"job"` // null: idle
|
||||
}
|
||||
|
||||
type APIRecipe struct {
|
||||
Item string `json:"item"` // refine recipe=<item>&qty=<n> (or all=1)
|
||||
Tier int `json:"tier"`
|
||||
Seconds int `json:"seconds"` // one unit
|
||||
Price float64 `json:"price"` // base sell price
|
||||
SellPrice float64 `json:"sellPrice"`
|
||||
Inputs []APIPart `json:"inputs"` // per unit, with what the player has
|
||||
InputValue float64 `json:"inputValue"`
|
||||
Gain float64 `json:"gain"` // percent over inputValue
|
||||
MaxQty int `json:"maxQty"` // units one batch can make now
|
||||
BulkQty int `json:"bulkQty"`
|
||||
BulkSecs int `json:"bulkSecs"` // a batch of bulkQty, with every bonus (see rules.refinery)
|
||||
BulkBonus float64 `json:"bulkBonus"`
|
||||
Owned float64 `json:"owned"`
|
||||
Why string `json:"why"` // busy (no free slot), inputs; "" can refine
|
||||
WhyText string `json:"whyText"`
|
||||
}
|
||||
|
||||
func apiRefinery(p *Page) *APIRefinery {
|
||||
s, rp := p.S, p.Refinery
|
||||
now := s.Now.UnixMilli()
|
||||
speed := s.Bonus().Refine * s.CycleBoosts.For("all")
|
||||
r := &APIRefinery{Slots: []APIRefinerySlot{}, SlotCount: rp.SlotCount, MaxSlots: rp.MaxSlots, NextSlot: rp.NextSlot,
|
||||
NextSlotPrice: rp.NextSlotPrice, CanBuySlot: rp.CanBuySlot, Free: rp.Free, Ready: rp.Ready, MaxBatch: rp.MaxBatch,
|
||||
Recipes: []APIRecipe{}, Stock: []APIAmount{}}
|
||||
if rp.NextSlotPrice > 0 {
|
||||
r.BuySlot = &APIConfirm{Title: "New refinery slot", Text: rp.SlotConfirm(), OK: "Buy slot"}
|
||||
r.BuySlotWhy = whyShort(rp.CanBuySlot, s.Balance, rp.NextSlotPrice)
|
||||
}
|
||||
for _, slot := range rp.Slots {
|
||||
row := APIRefinerySlot{N: slot.N}
|
||||
if j := slot.Job; j != nil {
|
||||
job := apiJob(j.RefineryJob, now)
|
||||
job.Progress = j.Progress
|
||||
if j.Ready {
|
||||
job.Progress = 1
|
||||
}
|
||||
row.Job = &job
|
||||
}
|
||||
r.Slots = append(r.Slots, row)
|
||||
}
|
||||
for _, card := range rp.Recipes {
|
||||
rc := APIRecipe{Item: card.Item.Name, Tier: card.Item.Tier, Seconds: card.Item.Seconds, Price: card.Item.Price,
|
||||
SellPrice: s.sellPrice(card.Item.Name) * cfg().MarketPressure.Factor(s.Pressure[card.Item.Name]),
|
||||
Inputs: []APIPart{}, InputValue: card.InputValue, Gain: card.Gain, MaxQty: card.MaxQty, BulkQty: card.BulkQty,
|
||||
BulkSecs: int(math.Round(cfg().Refinery.batchSecs(card.Item, card.BulkQty) / speed)),
|
||||
BulkBonus: card.BulkBonus, Owned: card.Owned}
|
||||
for _, in := range card.Inputs {
|
||||
rc.Inputs = append(rc.Inputs, APIPart{Name: in.Name, Need: in.Need, Have: in.Have})
|
||||
}
|
||||
switch {
|
||||
case rp.Free == 0:
|
||||
rc.Why = "busy"
|
||||
case card.MaxQty == 0:
|
||||
rc.Why = "inputs"
|
||||
}
|
||||
rc.WhyText = rp.Why(card)
|
||||
r.Recipes = append(r.Recipes, rc)
|
||||
}
|
||||
for _, st := range rp.Stock {
|
||||
r.Stock = append(r.Stock, APIAmount{Name: st.Item, Amount: st.Balance})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ advisors
|
||||
|
||||
type APIAdvisors struct {
|
||||
Seats int `json:"seats"`
|
||||
Hired int `json:"hired"`
|
||||
MaxSeats int `json:"maxSeats"`
|
||||
NextSeat int `json:"nextSeat"`
|
||||
NextSeatPrice float64 `json:"nextSeatPrice"` // 0 at the cap
|
||||
CanBuySeat bool `json:"canBuySeat"`
|
||||
SeatFree bool `json:"seatFree"`
|
||||
Bonuses []APIBonusRow `json:"bonuses"`
|
||||
Advisors []APIAdvisorUp `json:"advisors"`
|
||||
BuySeat *APIConfirm `json:"buySeat"` // before buy-seat; null at the cap
|
||||
BuySeatWhy string `json:"buySeatWhyText"` // why "+ Seat" is disabled, "" if it isn't
|
||||
}
|
||||
|
||||
type APIAdvisorUp struct {
|
||||
Name string `json:"name"` // hire advisor=<name>
|
||||
Title string `json:"title"`
|
||||
Image string `json:"image"`
|
||||
Description string `json:"description"`
|
||||
Price float64 `json:"price"`
|
||||
Effect APIEffect `json:"effect"`
|
||||
Hired bool `json:"hired"`
|
||||
CanAfford bool `json:"canAfford"`
|
||||
CanHire bool `json:"canHire"` // not hired, a seat free, affordable
|
||||
EffectText string `json:"effectText"`
|
||||
Why string `json:"why"` // hired, seats (none free), credits; "" can hire
|
||||
WhyText string `json:"whyText"`
|
||||
}
|
||||
|
||||
func apiAdvisors(p *Page) *APIAdvisors {
|
||||
ap := p.Advisors
|
||||
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
|
||||
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(),
|
||||
Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}}
|
||||
if ap.NextSeatPrice > 0 {
|
||||
a.BuySeat = &APIConfirm{Title: "New seat", Text: ap.SeatConfirm(), OK: "Buy seat"}
|
||||
a.BuySeatWhy = whyShort(ap.CanBuySeat, p.S.Balance, ap.NextSeatPrice)
|
||||
}
|
||||
for _, c := range ap.Cards {
|
||||
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Image: advisorImages + c.Image,
|
||||
Description: c.Description, Price: c.Price, Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)),
|
||||
Hired: c.Hired, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(), EffectText: c.Advisor.Effect()}
|
||||
switch {
|
||||
case c.Hired:
|
||||
up.Why, up.WhyText = "hired", "On your board"
|
||||
case !ap.SeatFree():
|
||||
up.Why, up.WhyText = "seats", ap.HireWhy(c, p.S.Balance)
|
||||
case !c.CanAfford:
|
||||
up.Why, up.WhyText = "credits", ap.HireWhy(c, p.S.Balance)
|
||||
}
|
||||
a.Advisors = append(a.Advisors, up)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ leaderboard, help, new moon
|
||||
|
||||
type APILeaderboard struct {
|
||||
Players int `json:"players"`
|
||||
Top []APIRankRow `json:"top"`
|
||||
You *APIRankRow `json:"you"` // null when the player is in top
|
||||
Me *APIRankRowShort `json:"me"` // the player's place either way
|
||||
}
|
||||
|
||||
type APIRankRow struct {
|
||||
Rank int `json:"rank"`
|
||||
Name string `json:"name"`
|
||||
Credits float64 `json:"credits"`
|
||||
Me bool `json:"me"`
|
||||
}
|
||||
|
||||
type APIRankRowShort struct {
|
||||
Rank int `json:"rank"`
|
||||
Credits float64 `json:"credits"`
|
||||
}
|
||||
|
||||
func apiLeaderboard(lb *LeaderboardPage) *APILeaderboard {
|
||||
a := &APILeaderboard{Players: lb.Players, Top: []APIRankRow{}}
|
||||
for _, r := range lb.Top {
|
||||
a.Top = append(a.Top, APIRankRow{Rank: r.Rank, Name: r.Name, Credits: r.Balance, Me: r.Me})
|
||||
if r.Me {
|
||||
a.Me = &APIRankRowShort{Rank: r.Rank, Credits: r.Balance}
|
||||
}
|
||||
}
|
||||
if r := lb.You; r != nil {
|
||||
a.You = &APIRankRow{Rank: r.Rank, Name: r.Name, Credits: r.Balance, Me: true}
|
||||
a.Me = &APIRankRowShort{Rank: r.Rank, Credits: r.Balance}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// APIHelp is the Help section: the steps of "How to play" (the same text as the template's
|
||||
// help-section; keep them in step). The note is reworded for clients outside a browser.
|
||||
type APIHelp struct {
|
||||
Steps []string `json:"steps"`
|
||||
Note string `json:"note"`
|
||||
NewGame APIConfirm `json:"newGame"` // before new-game
|
||||
}
|
||||
|
||||
// newGameConfirm is the help section's "Start a new game" question (the template's
|
||||
// hx-confirm; keep them in step).
|
||||
const newGameConfirm = "Start over with a new anonymous miner? Your current progress stays on the server but you won't be able to get back to it."
|
||||
|
||||
func apiHelp(s *State) *APIHelp {
|
||||
return &APIHelp{Steps: []string{
|
||||
"Claim a moon. It comes with " + s.WellRange() + " resource wells; which resources depends on the kind of moon.",
|
||||
"Buy mining tools in the Store.",
|
||||
"Pick a well on your moon and deploy a tool on it.",
|
||||
`When the drill finishes, claim what it dug up. "Claim and redeploy" puts the tool straight back to work; after a claim, "Redrill" does the same.`,
|
||||
"Sell resources in the Market, upgrade your equipment, claim more moons.",
|
||||
"Buy attachments in the Store and equip them on a drill (click it in Equipment) to make it faster or bigger. Hire advisors to boost your whole operation.",
|
||||
}, Note: "Your progress is saved on this device.",
|
||||
NewGame: APIConfirm{Title: "Start over", Text: newGameConfirm, OK: "Start over", Danger: true}}
|
||||
}
|
||||
|
||||
type APINewMoon struct {
|
||||
Price float64 `json:"price"`
|
||||
CanAfford bool `json:"canAfford"`
|
||||
After float64 `json:"after"` // balance after buying
|
||||
Need float64 `json:"need"` // credits missing
|
||||
Types []string `json:"types"`
|
||||
Resources []string `json:"resources"`
|
||||
WellsMin int `json:"wellsMin"`
|
||||
WellsMax int `json:"wellsMax"`
|
||||
WhyText string `json:"whyText"` // why "Claim and reveal" is disabled, "" if it isn't
|
||||
}
|
||||
|
||||
func apiNewMoon(t *NewMoonTeaser) *APINewMoon {
|
||||
lo, hi := wellCountRange()
|
||||
a := &APINewMoon{Price: t.Price, CanAfford: t.CanAfford, After: t.After, Need: t.Need, Types: []string{},
|
||||
Resources: []string{}, WellsMin: lo, WellsMax: hi}
|
||||
if !t.CanAfford {
|
||||
a.WhyText = "Not enough credits"
|
||||
}
|
||||
a.Types = append(a.Types, t.Types...)
|
||||
a.Resources = append(a.Resources, t.Resources...)
|
||||
return a
|
||||
}
|
||||
|
||||
// APIReveal is the reveal card after buy-moon (the act response's reveal).
|
||||
type APIReveal struct {
|
||||
MoonID string `json:"moonId"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Description string `json:"description"`
|
||||
Rating APIRating `json:"rating"`
|
||||
Headline string `json:"headline"`
|
||||
Sub string `json:"sub"`
|
||||
Worth float64 `json:"worth"` // its wells at market prices now
|
||||
Multiple float64 `json:"multiple"` // worth / its price
|
||||
Traits []string `json:"traits"`
|
||||
MaxSupply float64 `json:"maxSupply"` // a full bar
|
||||
FullAt float64 `json:"fullAt"` // wells from here count as full
|
||||
Wells []APIRevealWell `json:"wells"`
|
||||
}
|
||||
|
||||
type APIRevealWell struct {
|
||||
ID string `json:"id"`
|
||||
Resource string `json:"resource"`
|
||||
Supply float64 `json:"supply"`
|
||||
Full bool `json:"full"`
|
||||
}
|
||||
|
||||
func apiReveal(r *MoonReveal) *APIReveal {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
a := &APIReveal{MoonID: r.ID, Name: r.Name, Kind: r.Kind, Description: r.Description,
|
||||
Rating: *apiRating(&r.Rating),
|
||||
Headline: r.Headline, Sub: r.Sub, Worth: r.Worth, Multiple: r.Multiple, Traits: []string{}, MaxSupply: r.MaxSupply,
|
||||
FullAt: r.FullAt, Wells: []APIRevealWell{}}
|
||||
a.Traits = append(a.Traits, r.Traits...)
|
||||
for _, w := range r.Wells {
|
||||
a.Wells = append(a.Wells, APIRevealWell{ID: w.ID, Resource: w.Resource, Supply: w.Supply, Full: w.Supply >= r.FullAt})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ moon sale
|
||||
|
||||
type APIMoonSale struct {
|
||||
Total float64 `json:"total"` // the offer
|
||||
Market float64 `json:"market"` // the wells at market
|
||||
Replace float64 `json:"replace"` // a new moon's price after the sale
|
||||
Delta float64 `json:"delta"` // total - replace
|
||||
Capped bool `json:"capped"`
|
||||
Factor float64 `json:"factor"`
|
||||
Lines []APISaleLine `json:"lines"`
|
||||
Last bool `json:"last"`
|
||||
Running int `json:"running"`
|
||||
Ready int `json:"ready"` // finished drills, claimed in the sale
|
||||
HoldEnd *int64 `json:"holdEnd"` // sellable from then, null if already
|
||||
CanSell bool `json:"canSell"`
|
||||
Why string `json:"why"` // last, running, hold; "" can sell
|
||||
WhyText string `json:"whyText"`
|
||||
Confirm APIConfirm `json:"confirm"` // before sell-moon
|
||||
}
|
||||
|
||||
type APISaleLine struct {
|
||||
Resource string `json:"resource"`
|
||||
Supply float64 `json:"supply"`
|
||||
Rate float64 `json:"rate"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
func apiMoonSale(m *MoonSale) *APIMoonSale {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
a := &APIMoonSale{Total: m.Total, Market: m.Market, Replace: m.Replace, Delta: m.Delta(), Capped: m.Capped,
|
||||
Factor: m.Factor(), Lines: []APISaleLine{}, Last: m.Last, Running: m.Running, Ready: m.Ready,
|
||||
HoldEnd: unixMS(m.HoldEnd), CanSell: !m.Blocked(), WhyText: m.Why(),
|
||||
Confirm: APIConfirm{Title: "Sell " + m.Name, Text: m.Confirm(), OK: "Sell for " + formatCredits(m.Total), Danger: true}}
|
||||
switch {
|
||||
case m.Last:
|
||||
a.Why = "last"
|
||||
case m.Running > 0:
|
||||
a.Why = "running"
|
||||
case !m.HoldEnd.IsZero():
|
||||
a.Why = "hold"
|
||||
}
|
||||
for _, l := range m.Lines {
|
||||
a.Lines = append(a.Lines, APISaleLine{Resource: l.Resource, Supply: l.Supply, Rate: l.Rate, Value: l.Value})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ market chart
|
||||
|
||||
// APIChart is the selected quote's 24h market rate: one point per step from start.
|
||||
type APIChart struct {
|
||||
Item string `json:"item"`
|
||||
Start int64 `json:"start"`
|
||||
Step int64 `json:"step"` // ms
|
||||
Prices []float64 `json:"prices"`
|
||||
}
|
||||
|
||||
// spark is every 3rd point of a series and the last one, as the web's sparklines draw it.
|
||||
func spark(series []float64) []float64 {
|
||||
out := []float64{}
|
||||
last := len(series) - 1
|
||||
for i := 0; i <= last; i += 3 {
|
||||
if i+3 > last {
|
||||
i = last
|
||||
}
|
||||
out = append(out, series[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
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"
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPI(t *testing.T) {
|
||||
// Every tool has art at every tier (scripts/tool-art.mjs), else its URLs fall back.
|
||||
for _, item := range game.StoreItems {
|
||||
for tier := 0; tier <= len(item.Upgrades); tier++ {
|
||||
name := item.ImageAt(tier)
|
||||
if _, err := fs.Stat(embedded, "web/static/assets/items/"+name); err != nil || (tier > 0 && name == item.Image) {
|
||||
t.Errorf("%s: no image for tier %d (run node scripts/tool-art.mjs)", item.Name, tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
unlocked := newPlayerState()
|
||||
unlocked.Onboarding = []string{"all"}
|
||||
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} {
|
||||
for _, v := range testViews(s) {
|
||||
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 == "" {
|
||||
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)
|
||||
}
|
||||
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 (a.Market != nil) != s.Unlocked("market") {
|
||||
t.Errorf("view %+v: market is %v, unlocked %v", v, a.Market != nil, s.Unlocked("market"))
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
for _, d := range w.Drills {
|
||||
if d.Fate == "" {
|
||||
fail("drill fate", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sale := m.Sale; sale != nil && ((sale.Why != "") != (sale.WhyText != "") || sale.Confirm.Text == "") {
|
||||
fail("moon sale", sale)
|
||||
}
|
||||
}
|
||||
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 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) {
|
||||
t.Errorf("view %+v: advisors %+v", v, a.Advisors)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -230,16 +231,42 @@ type SpareGear struct {
|
||||
Why string // why it can't be equipped
|
||||
}
|
||||
|
||||
func (s *State) drillPanel(row ToolRow) *DrillPanel {
|
||||
d := &DrillPanel{ToolRow: row, Mining: row.Stake != nil, Resale: row.Item.ToolResale(row.Tier)}
|
||||
// noSale is why a tool can't be sold (only-tool, claim-first, mining), "" if it can.
|
||||
func (s *State) noSale(row ToolRow) string {
|
||||
switch {
|
||||
case len(s.Tools) <= 1:
|
||||
d.NoSale = "You can't sell your only tool"
|
||||
return "only-tool"
|
||||
case row.Stake != nil && row.Stake.Ready:
|
||||
d.NoSale = "Claim its haul first"
|
||||
return "claim-first"
|
||||
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 {
|
||||
slot := SlotGear{N: i + 1}
|
||||
if i < len(row.Gear) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: moonminers
|
||||
|
||||
# Local development database. Run the app itself with `make dev`.
|
||||
services:
|
||||
db:
|
||||
|
||||
@@ -177,6 +177,10 @@ type BannerItem struct {
|
||||
Level string // info, warning, success, event
|
||||
Text string
|
||||
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.
|
||||
@@ -194,7 +198,7 @@ func loadBanner(ctx context.Context, q querier) ([]BannerItem, error) {
|
||||
}
|
||||
var items []BannerItem
|
||||
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
|
||||
@@ -207,7 +211,8 @@ func loadBanner(ctx context.Context, q querier) ([]BannerItem, error) {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"math"
|
||||
mrand "math/rand/v2"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -183,6 +186,20 @@ func (item *StoreItem) ToolReach(tier int) int {
|
||||
return max(1, reach)
|
||||
}
|
||||
|
||||
// ImageAt is the tool's image at a tier: tier N is the image's name with "-tN" before the
|
||||
// extension (drawn by scripts/tool-art.mjs), else the tier 0 image when that file is missing.
|
||||
func (item *StoreItem) ImageAt(tier int) string {
|
||||
if tier <= 0 {
|
||||
return item.Image
|
||||
}
|
||||
ext := path.Ext(item.Image)
|
||||
name := fmt.Sprintf("%s-t%d%s", strings.TrimSuffix(item.Image, ext), tier, ext)
|
||||
if _, err := fs.Stat(embedded, "web/static/assets/items/"+name); err != nil {
|
||||
return item.Image
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (item *StoreItem) NextUpgrade(tier int) *Upgrade {
|
||||
for i := range item.Upgrades {
|
||||
if item.Upgrades[i].Tier == tier+1 {
|
||||
|
||||
@@ -134,7 +134,7 @@ func markSeen(ctx context.Context, db *pgxpool.Pool, userID, section string) 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{
|
||||
"sell-moon": "store", "sell-tool": "store", "sell-attachment": "store",
|
||||
"buy-tool": "store", "upgrade": "store", "buy-attachment": "store",
|
||||
|
||||
@@ -553,6 +553,22 @@ type RefineryPage struct {
|
||||
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 {
|
||||
N int
|
||||
Job *JobRow
|
||||
|
||||
@@ -161,7 +161,15 @@ func (p *Page) moonReveal() *MoonReveal {
|
||||
|
||||
// WellRange is how many wells a new moon can have, e.g. "2–5".
|
||||
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 {
|
||||
if w > 0 {
|
||||
if lo == 0 || n < lo {
|
||||
@@ -170,8 +178,5 @@ func (s *State) WellRange() string {
|
||||
hi = max(hi, n)
|
||||
}
|
||||
}
|
||||
if lo == hi {
|
||||
return strconv.Itoa(lo)
|
||||
}
|
||||
return strconv.Itoa(lo) + "–" + strconv.Itoa(hi)
|
||||
return lo, hi
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
// Tool art: draws every store item (drill) at every tier as an SVG and rasterizes it into
|
||||
// web/static/assets/items (rsvg-convert + ImageMagick). Tier 0 keeps the item's `image` name
|
||||
// from config/game.json; tier N is the same name with "-tN" before the extension (toolImage
|
||||
// in game.go). Run from the repo root: node scripts/tool-art.mjs [--svg dir]
|
||||
//
|
||||
// Each tool has its own silhouette and color family; tiers grow the rig, add parts, light
|
||||
// up a ring of tier segments around its base and strengthen the glow.
|
||||
|
||||
import { readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const SIZE = 512;
|
||||
const OUT = "web/static/assets/items";
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
const C = Math.cos(Math.PI / 6);
|
||||
const R2 = Math.SQRT2;
|
||||
const f = (n) => +n.toFixed(1);
|
||||
|
||||
// Isometric projection: X runs down-right, Y down-left, Z up.
|
||||
// While BB is set, every projected point grows it (the rig's bounding box, for fitting).
|
||||
let BB = null;
|
||||
function grow(x, y, r = 0) {
|
||||
if (!BB) return;
|
||||
BB.x0 = Math.min(BB.x0, x - r); BB.x1 = Math.max(BB.x1, x + r);
|
||||
BB.y0 = Math.min(BB.y0, y - r); BB.y1 = Math.max(BB.y1, y + r);
|
||||
}
|
||||
const P = (x, y, z) => {
|
||||
const p = [f((x - y) * C), f((x + y) * 0.5 - z)];
|
||||
grow(...p);
|
||||
return p;
|
||||
};
|
||||
const pts = (list) => list.map((p) => p.join(",")).join(" ");
|
||||
|
||||
function hex(c) {
|
||||
const n = parseInt(c.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
function mix(a, b, t) {
|
||||
const [x, y] = [hex(a), hex(b)];
|
||||
return "#" + x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
const shade = (c, k) => (k >= 0 ? mix(c, "#ffffff", k) : mix(c, "#000000", -k));
|
||||
|
||||
// A seeded random, so the rocks stay put between runs.
|
||||
function rng(seed) {
|
||||
let s = seed * 2654435761 % 4294967296;
|
||||
return () => ((s = (s * 1664525 + 1013904223) % 4294967296) / 4294967296);
|
||||
}
|
||||
|
||||
// Materials: hull plating, dark machinery, and the tool's own color.
|
||||
const HULL = "#b9c8db";
|
||||
const IRON = "#3a4a63";
|
||||
|
||||
class Art {
|
||||
constructor(color) {
|
||||
this.color = color;
|
||||
this.out = [];
|
||||
}
|
||||
add(s) {
|
||||
this.out.push(s);
|
||||
}
|
||||
poly(list, fill, extra = "") {
|
||||
this.add(`<polygon points="${pts(list)}" fill="${fill}" ${extra}/>`);
|
||||
}
|
||||
line(a, b, stroke, w = 2, extra = "") {
|
||||
this.add(`<line x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}" stroke="${stroke}" stroke-width="${w}" stroke-linecap="round" ${extra}/>`);
|
||||
}
|
||||
// Box from its min corner (x, y, z), size w (X), d (Y), h (Z): top, right (+X), left (+Y).
|
||||
box(x, y, z, w, d, h, col, edge = true) {
|
||||
const st = edge ? `stroke="${shade(col, -0.55)}" stroke-width="1" stroke-linejoin="round"` : "";
|
||||
this.poly([P(x, y + d, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x, y + d, z + h)], shade(col, -0.18), st);
|
||||
this.poly([P(x + w, y, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x + w, y, z + h)], shade(col, -0.45), st);
|
||||
this.poly([P(x, y, z + h), P(x + w, y, z + h), P(x + w, y + d, z + h), P(x, y + d, z + h)], shade(col, 0.22), st);
|
||||
}
|
||||
cbox(cx, cy, z, w, d, h, col, edge) {
|
||||
this.box(cx - w / 2, cy - d / 2, z, w, d, h, col, edge);
|
||||
}
|
||||
// Vertical cylinder centered on (cx, cy), from z up h.
|
||||
cyl(cx, cy, z, r, h, col) {
|
||||
const [bx, by] = P(cx, cy, z);
|
||||
const [, ty] = P(cx, cy, z + h);
|
||||
const rx = f(r * C * R2), ry = f(r * R2 * 0.5);
|
||||
const g = this.grad(col);
|
||||
this.add(`<path d="M${bx - rx},${ty} L${bx - rx},${by} A${rx},${ry} 0 0 0 ${bx + rx},${by} L${bx + rx},${ty} Z" fill="url(#${g})" stroke="${shade(col, -0.55)}" stroke-width="1"/>`);
|
||||
this.add(`<ellipse cx="${bx}" cy="${ty}" rx="${rx}" ry="${ry}" fill="${shade(col, 0.25)}" stroke="${shade(col, -0.5)}" stroke-width="1"/>`);
|
||||
}
|
||||
// A flat disc (ellipse) lying at height z.
|
||||
disc(cx, cy, z, r, fill, extra = "") {
|
||||
const [x, y] = P(cx, cy, z);
|
||||
this.add(`<ellipse cx="${x}" cy="${y}" rx="${f(r * C * R2)}" ry="${f(r * R2 * 0.5)}" fill="${fill}" ${extra}/>`);
|
||||
}
|
||||
// Cone (drill bit) from a disc of radius r at z1 down to a point at z2.
|
||||
cone(cx, cy, z1, z2, r, col) {
|
||||
const [x, y1] = P(cx, cy, z1);
|
||||
const [, y2] = P(cx, cy, z2);
|
||||
const rx = f(r * C * R2), ry = f(r * R2 * 0.5);
|
||||
this.add(`<path d="M${x - rx},${y1} A${rx},${ry} 0 0 0 ${x + rx},${y1} L${x},${y2} Z" fill="url(#${this.grad(col)})" stroke="${shade(col, -0.55)}" stroke-width="1"/>`);
|
||||
}
|
||||
// Horizontal shading gradient for round parts (lit from the left).
|
||||
grad(col) {
|
||||
const id = "g" + col.slice(1);
|
||||
this.defs ??= new Map();
|
||||
if (!this.defs.has(id)) {
|
||||
this.defs.set(id, `<linearGradient id="${id}" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="${shade(col, -0.25)}"/><stop offset="0.3" stop-color="${shade(col, 0.3)}"/>
|
||||
<stop offset="0.65" stop-color="${shade(col, -0.2)}"/><stop offset="1" stop-color="${shade(col, -0.6)}"/></linearGradient>`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
// A light: a bright core with a soft halo in the tool's color.
|
||||
light(p, r, k = 1, col = this.color) {
|
||||
this.add(`<circle cx="${p[0]}" cy="${p[1]}" r="${f(r * 2.6)}" fill="${col}" opacity="${f(0.35 * k * 10) / 10}" filter="url(#blur)"/>`);
|
||||
this.add(`<circle cx="${p[0]}" cy="${p[1]}" r="${r}" fill="${mix(col, "#ffffff", 0.6)}"/>`);
|
||||
}
|
||||
// Lattice between two square frames (a tower section): legs plus X bracing.
|
||||
lattice(cx, cy, z1, z2, half1, half2, col, w = 3, bays = 4) {
|
||||
const corner = (h, z) => [[cx - h, cy - h, z], [cx + h, cy - h, z], [cx + h, cy + h, z], [cx - h, cy + h, z]];
|
||||
const at = (t) => corner(half1 + (half2 - half1) * t, z1 + (z2 - z1) * t);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const j = (i + 1) % 4;
|
||||
if (i === 0) continue; // the back face would only add clutter
|
||||
for (let b = 0; b < bays; b++) {
|
||||
const lo = at(b / bays), hi = at((b + 1) / bays);
|
||||
this.line(P(...lo[i]), P(...hi[j]), shade(col, -0.25), w * 0.55);
|
||||
this.line(P(...lo[j]), P(...hi[i]), shade(col, -0.25), w * 0.55);
|
||||
this.line(P(...hi[i]), P(...hi[j]), shade(col, -0.1), w * 0.6);
|
||||
}
|
||||
}
|
||||
const lo = at(0), hi = at(1);
|
||||
for (let i = 0; i < 4; i++) this.line(P(...lo[i]), P(...hi[i]), i === 2 ? shade(col, 0.2) : col, w);
|
||||
}
|
||||
// Diagonal warning stripes on the +Y face of a box.
|
||||
stripes(x, y, z, w, d, h, n, col) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = x + (w / n) * i, b = a + (w / n) * 0.5;
|
||||
this.poly([P(a, y + d, z), P(b, y + d, z), P(b + h * 0.4, y + d, z + h), P(a + h * 0.4, y + d, z + h)], col, 'opacity="0.85"');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the tools
|
||||
|
||||
// Each draw(a, t) adds the rig's shapes around the ground origin (0, 0, 0); t is the tier.
|
||||
const TOOLS = {
|
||||
// Prospector Drone PD-1: a hovering quad-rotor that pulls ore up with a tractor beam.
|
||||
1: {
|
||||
color: "#7cf06a",
|
||||
size: 0.8,
|
||||
draw(a, t) {
|
||||
const c = a.color, z = 92;
|
||||
// tractor beam and its pool on the ground
|
||||
a.disc(0, 0, 0, 34, c, `opacity="${0.18 + t * 0.05}" filter="url(#blur)"`);
|
||||
const [bx, by] = P(0, 0, z), [, gy] = P(0, 0, 0);
|
||||
a.add(`<polygon points="${bx - 14},${by} ${bx + 14},${by} ${bx + 34},${gy} ${bx - 34},${gy}" fill="url(#beam)" opacity="${0.45 + t * 0.08}"/>`);
|
||||
const arms = [[-1, -1], [1, -1], [-1, 1], [1, 1]];
|
||||
const rotor = (sx, sy) => {
|
||||
const x = sx * 58, y = sy * 58;
|
||||
a.cyl(x, y, z + 6, 7, 10, IRON);
|
||||
a.disc(x, y, z + 17, 30, shade(c, 0.4), `opacity="0.18"`);
|
||||
a.disc(x, y, z + 17, 30, "none", `stroke="${c}" stroke-width="${t >= 4 ? 3 : 1.5}" opacity="0.9"`);
|
||||
a.light(P(x, y, z + 17), 2.5, 0.6);
|
||||
};
|
||||
// back rotors and arms first
|
||||
for (const [sx, sy] of arms.slice(0, 1)) rotor(sx, sy);
|
||||
for (const [sx, sy] of arms) a.line(P(0, 0, z + 10), P(sx * 58, sy * 58, z + 10), shade(HULL, -0.3), 7);
|
||||
for (const [sx, sy] of arms.slice(1, 3)) rotor(sx, sy);
|
||||
// body: a squat saucer with a dome
|
||||
a.cyl(0, 0, z - 6, 30, 16, HULL);
|
||||
a.cyl(0, 0, z + 10, 22, 6, IRON);
|
||||
const [dx, dy] = P(0, 0, z + 16);
|
||||
a.add(`<path d="M${dx - 22},${dy} A22,14 0 0 1 ${dx + 22},${dy} A22,9 0 0 1 ${dx - 22},${dy}" fill="${mix(c, "#0a2020", 0.55)}" stroke="${c}" stroke-width="1.5"/>`);
|
||||
a.light([dx, dy - 4], 4, 0.8 + t * 0.1);
|
||||
// side lamps grow in number with the tier
|
||||
for (let i = 0; i < 2 + Math.min(t, 3); i++) a.light(P(26, -18 + i * 10, z + 2), 2.2, 0.7);
|
||||
if (t >= 2) {
|
||||
a.line(P(-8, -8, z + 18), P(-14, -14, z + 50), shade(HULL, -0.2), 2);
|
||||
a.light(P(-14, -14, z + 50), 2.5, 1);
|
||||
}
|
||||
if (t >= 3) {
|
||||
a.cyl(-6, 34, z - 14, 9, 22, c);
|
||||
a.cyl(34, -6, z - 14, 9, 22, c);
|
||||
}
|
||||
rotor(1, 1);
|
||||
},
|
||||
},
|
||||
|
||||
// Percussion Drill PX-4: a tracked crawler with a hammer mast at its nose.
|
||||
2: {
|
||||
color: "#ff9a3d",
|
||||
size: 0.86,
|
||||
draw(a, t) {
|
||||
const c = a.color;
|
||||
// shock rings where the hammer bites
|
||||
for (let i = 0; i < 1 + Math.ceil(t / 2); i++) {
|
||||
a.disc(70, 0, 0, 22 + i * 14, "none", `stroke="${c}" stroke-width="2" opacity="${0.7 - i * 0.18}"`);
|
||||
}
|
||||
// tracks
|
||||
a.box(-70, -48, 0, 120, 22, 26, IRON);
|
||||
a.box(-70, 26, 0, 120, 22, 26, IRON);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const [x, y] = P(-60 + i * 25, 48, 13);
|
||||
a.add(`<ellipse cx="${x}" cy="${y}" rx="8" ry="10" fill="${shade(IRON, -0.4)}" stroke="${shade(IRON, 0.3)}" stroke-width="1.5"/>`);
|
||||
}
|
||||
// hull, cab and the stack
|
||||
a.box(-64, -40, 26, 108, 80, 26, HULL);
|
||||
a.stripes(-64, -40, 26, 108, 80, 26, 7, c);
|
||||
a.box(-60, -34, 52, 48, 56, 34 + (t >= 3 ? 10 : 0), HULL);
|
||||
a.poly([P(-12, -26, 60), P(-12, 16, 60), P(-12, 16, 80), P(-12, -26, 80)], mix(c, "#04121a", 0.5));
|
||||
a.cyl(-50, -24, 86 + (t >= 3 ? 10 : 0), 6, 26, IRON);
|
||||
if (t >= 2) a.light(P(-50, -24, 118 + (t >= 3 ? 10 : 0)), 3, 0.8);
|
||||
// the mast: a tall column with a piston block
|
||||
const top = 150 + t * 10;
|
||||
a.box(44, -12, 26, 16, 24, top - 26, shade(HULL, -0.1));
|
||||
a.stripes(44, -12, 30, 16, 24, top - 34, 6, c);
|
||||
a.cyl(70, 0, 30, 9, top - 40, shade(HULL, 0.1));
|
||||
a.cbox(70, 0, 46 + (top - 80) * 0.45, 30, 30, 34, c);
|
||||
a.cbox(70, 0, top - 10, 26, 26, 14, IRON);
|
||||
a.cone(70, 0, 30, -6, 10, shade(HULL, -0.2));
|
||||
a.light(P(70, 0, 2), 4, 0.6 + t * 0.1);
|
||||
if (t >= 4) {
|
||||
a.cyl(0, -8, 52, 12, 22, c);
|
||||
a.light(P(0, -8, 74), 3, 1);
|
||||
}
|
||||
a.light(P(70, 0, top + 4), 3, 0.9);
|
||||
},
|
||||
},
|
||||
|
||||
// LunarLite L100: a light tripod with a spiral rotary bit.
|
||||
3: {
|
||||
color: "#5fd4ff",
|
||||
size: 0.84,
|
||||
draw(a, t) {
|
||||
const c = a.color, hub = 130;
|
||||
a.disc(0, 0, 0, 22, c, `opacity="${0.3 + t * 0.07}" filter="url(#blur)"`);
|
||||
const legs = [[-70, -10], [20, -66], [40, 60]];
|
||||
a.line(P(...legs[0], 0), P(0, 0, hub), shade(HULL, -0.35), 6);
|
||||
a.line(P(...legs[1], 0), P(0, 0, hub), shade(HULL, -0.35), 6);
|
||||
// the bit: a spiral auger into the ground
|
||||
a.cone(0, 0, 76, -14, 14, shade(HULL, 0.05));
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const z = 70 - i * 13, r = 14 * (z + 14) / 90;
|
||||
const [x, y] = P(0, 0, z);
|
||||
a.add(`<path d="M${f(x - r * 1.2)},${y} q${f(r * 1.2)},${f(r * 0.7)} ${f(r * 2.4)},-5" fill="none" stroke="${c}" stroke-width="2" opacity="0.9"/>`);
|
||||
}
|
||||
// motor housing
|
||||
a.cyl(0, 0, 76, 16, 14, IRON);
|
||||
a.cyl(0, 0, 90, 24, 40 + (t >= 3 ? 8 : 0), HULL);
|
||||
a.disc(0, 0, 108, 24.5, "none", `stroke="${c}" stroke-width="3"`);
|
||||
if (t >= 1) a.disc(0, 0, 98, 24.5, "none", `stroke="${c}" stroke-width="2" opacity="0.7"`);
|
||||
a.cyl(0, 0, 130 + (t >= 3 ? 8 : 0), 10, 10, IRON);
|
||||
a.light(P(0, 0, 146 + (t >= 3 ? 8 : 0)), 3, 0.8 + t * 0.1);
|
||||
if (t >= 2) {
|
||||
a.cbox(-34, 18, 0, 22, 22, 26, c);
|
||||
a.line(P(-34, 18, 26), P(0, 0, 100), IRON, 2);
|
||||
}
|
||||
if (t >= 4) a.cyl(30, -30, 0, 9, 34, c);
|
||||
a.line(P(...legs[2], 0), P(0, 0, hub), shade(HULL, -0.1), 6);
|
||||
for (const l of legs) a.disc(l[0], l[1], 0, 7, IRON);
|
||||
},
|
||||
},
|
||||
|
||||
// LunarLite L300: a tall derrick with a laser cutter firing straight down.
|
||||
4: {
|
||||
color: "#ff5fd2",
|
||||
size: 0.94,
|
||||
draw(a, t) {
|
||||
const c = a.color, top = 230 + t * 12;
|
||||
// laser impact
|
||||
a.disc(0, 0, 0, 30 + t * 4, c, `opacity="0.55" filter="url(#blur)"`);
|
||||
a.disc(0, 0, 0, 12, "#ffffff", `opacity="0.8" filter="url(#blur)"`);
|
||||
// power unit at the back
|
||||
a.box(-96, -70, 0, 40, 36, 34, HULL);
|
||||
a.stripes(-96, -70, 0, 40, 36, 34, 4, c);
|
||||
a.box(-46, -50, 0, 92, 100, 10, IRON);
|
||||
a.lattice(0, 0, 10, top, 42, 13, shade(HULL, -0.15), 4, 5);
|
||||
// emitter and beam
|
||||
const ez = 96;
|
||||
const [bx, by] = P(0, 0, ez), [, gy] = P(0, 0, 0);
|
||||
a.add(`<rect x="${bx - 8}" y="${by}" width="16" height="${gy - by}" fill="${c}" opacity="0.9" filter="url(#blur)"/>`);
|
||||
a.add(`<rect x="${bx - 1.8}" y="${by}" width="4" height="${gy - by}" fill="#ffe6f8"/>`);
|
||||
a.cbox(0, 0, ez, 26, 26, 30, HULL);
|
||||
a.disc(0, 0, ez + 12, 18.5, "none", `stroke="${c}" stroke-width="3"`);
|
||||
a.cone(0, 0, ez, ez - 16, 9, IRON);
|
||||
if (t >= 2) a.cbox(0, 0, ez + 30, 16, 16, 24, c);
|
||||
// crown block
|
||||
a.cbox(0, 0, top, 34, 34, 14, c);
|
||||
a.light(P(0, 0, top + 22), 3.5, 0.9 + t * 0.1);
|
||||
if (t >= 3) {
|
||||
a.cyl(56, 30, 0, 12, 44, c);
|
||||
a.light(P(56, 30, 48), 3, 0.8);
|
||||
}
|
||||
if (t >= 4) {
|
||||
for (const z of [ez + 70, ez + 110]) a.disc(0, 0, z, 30, "none", `stroke="${c}" stroke-width="2.5" opacity="0.85"`);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Lunar Regolith Extractor LRE-3000: a bucket-wheel excavator on tracks.
|
||||
5: {
|
||||
color: "#ffd24a",
|
||||
size: 0.95,
|
||||
draw(a, t) {
|
||||
const c = a.color;
|
||||
// counterweight boom (behind)
|
||||
a.line(P(-10, 0, 80), P(-120, 0, 106), shade(HULL, -0.35), 12);
|
||||
a.line(P(-30, 0, 120), P(-120, 0, 122), shade(HULL, 0.05), 3);
|
||||
a.cbox(-120, 0, 92, 30, 34, 30, IRON);
|
||||
// tracks and turntable
|
||||
a.box(-60, -56, 0, 100, 26, 24, IRON);
|
||||
a.box(-60, 30, 0, 100, 26, 24, IRON);
|
||||
a.cyl(-10, 0, 24, 40, 10, IRON);
|
||||
// superstructure
|
||||
a.box(-50, -34, 34, 74, 68, 46, HULL);
|
||||
a.stripes(-50, -34, 34, 74, 68, 16, 6, c);
|
||||
a.box(-36, -24, 80, 34, 40, 26 + (t >= 3 ? 10 : 0), HULL);
|
||||
a.poly([P(-2, -18, 88), P(-2, 10, 88), P(-2, 10, 100), P(-2, -18, 100)], mix(c, "#141004", 0.4));
|
||||
// boom out to the wheel
|
||||
const wx = 130, wz = 74;
|
||||
a.line(P(10, 0, 70), P(wx, 0, wz + 6), shade(HULL, -0.1), 12);
|
||||
a.line(P(10, 0, 116), P(wx - 6, 0, wz + 26), shade(HULL, 0.1), 3);
|
||||
a.line(P(-20, 0, 110), P(10, 0, 116), shade(HULL, 0.1), 3);
|
||||
// the bucket wheel, facing the viewer
|
||||
const [x, y] = P(wx, 0, wz);
|
||||
const R = 52 + t * 3, n = 8 + t * 2;
|
||||
grow(x, y, R + 9);
|
||||
a.add(`<circle cx="${x}" cy="${y}" r="${R}" fill="none" stroke="${shade(HULL, -0.2)}" stroke-width="9"/>`);
|
||||
a.add(`<circle cx="${x}" cy="${y}" r="${R}" fill="none" stroke="${c}" stroke-width="2"/>`);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const ang = (i / n) * Math.PI * 2;
|
||||
const ex = x + Math.cos(ang) * R, ey = y + Math.sin(ang) * R;
|
||||
a.line([x, y], [f(ex), f(ey)], shade(HULL, -0.3), 2.5);
|
||||
a.add(`<rect x="${f(ex - 8)}" y="${f(ey - 7)}" width="16" height="14" rx="3" fill="${c}" stroke="${shade(c, -0.6)}" stroke-width="1" transform="rotate(${f((ang * 180) / Math.PI + 90)} ${f(ex)} ${f(ey)})"/>`);
|
||||
}
|
||||
a.add(`<circle cx="${x}" cy="${y}" r="12" fill="${IRON}" stroke="${c}" stroke-width="2"/>`);
|
||||
a.light([x, y], 4, 0.7 + t * 0.1);
|
||||
if (t >= 2) a.light(P(-36, -24, 108 + (t >= 3 ? 10 : 0)), 3, 0.9);
|
||||
if (t >= 4) {
|
||||
a.cyl(22, 40, 34, 10, 30, c);
|
||||
a.light(P(22, 40, 66), 2.5, 1);
|
||||
}
|
||||
// dust thrown up by the wheel
|
||||
const r = rng(5);
|
||||
for (let i = 0; i < 6 + t * 3; i++) {
|
||||
a.add(`<circle cx="${f(x - 20 + r() * 70)}" cy="${f(y + R * 0.4 + r() * 50)}" r="${f(1 + r() * 2.5)}" fill="${mix(c, "#ffffff", 0.3)}" opacity="${f(0.3 + r() * 0.5)}"/>`);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Electrostatic Dust Excavator EDE-700: a coil tower of charged plates with arcs.
|
||||
6: {
|
||||
color: "#a98bff",
|
||||
size: 0.97,
|
||||
draw(a, t) {
|
||||
const c = a.color, plates = 3 + Math.min(t, 3);
|
||||
a.disc(0, 0, 0, 80, c, `opacity="${0.15 + t * 0.05}" filter="url(#blur)"`);
|
||||
// base platform with feet
|
||||
a.cyl(0, 0, 0, 62, 16, IRON);
|
||||
for (const [x, y] of [[-60, 0], [0, -60], [60, 0], [0, 60]]) a.cbox(x, y, 0, 18, 18, 22, HULL);
|
||||
a.cyl(0, 0, 16, 30, 26, HULL);
|
||||
// the column and its plates, widest at the bottom
|
||||
const h = 60 + plates * 30;
|
||||
a.cyl(0, 0, 40, 13, h, shade(HULL, -0.05));
|
||||
const r = rng(6);
|
||||
for (let i = 0; i < plates; i++) {
|
||||
const z = 58 + i * 30, rad = 54 - i * 5;
|
||||
a.cyl(0, 0, z, rad, 6, HULL);
|
||||
a.disc(0, 0, z + 6, rad + 0.5, "none", `stroke="${c}" stroke-width="2.5"`);
|
||||
a.disc(0, 0, z + 6, rad, c, `opacity="0.25" filter="url(#blur)"`);
|
||||
a.cyl(0, 0, z + 6, 14, 24, shade(HULL, -0.05));
|
||||
}
|
||||
// arcs between the plates
|
||||
for (let i = 0; i < Math.min(plates - 1, 1 + t); i++) {
|
||||
const z = 64 + i * 30, side = i % 2 ? 1 : -1;
|
||||
let [x, y] = P(side * 36, side * -10, z);
|
||||
let d = `M${x},${y}`;
|
||||
for (let k = 0; k < 4; k++) d += ` L${f(x + side * (4 + r() * 10))},${f(y - 7 - k * 6)}`;
|
||||
a.add(`<path d="${d}" fill="none" stroke="${c}" stroke-width="5" opacity="0.5" filter="url(#blur)"/>`);
|
||||
a.add(`<path d="${d}" fill="none" stroke="#f2ecff" stroke-width="1.5"/>`);
|
||||
}
|
||||
// the top sphere
|
||||
const [tx, ty] = P(0, 0, 40 + h + 14);
|
||||
grow(tx, ty, 16 + t);
|
||||
a.add(`<circle cx="${tx}" cy="${ty}" r="${16 + t}" fill="url(#orb)" stroke="${c}" stroke-width="1.5"/>`);
|
||||
a.light([tx, ty], 5, 0.8 + t * 0.12);
|
||||
// dust rising into the field
|
||||
for (let i = 0; i < 8 + t * 4; i++) {
|
||||
const ang = r() * Math.PI * 2, rr = 50 + r() * 50;
|
||||
const [px, py] = P(Math.cos(ang) * rr, Math.sin(ang) * rr, r() * h);
|
||||
a.add(`<circle cx="${px}" cy="${py}" r="${f(1 + r() * 2)}" fill="${mix(c, "#ffffff", 0.4)}" opacity="${f(0.3 + r() * 0.6)}"/>`);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Lunar Core Extractor LCE-360: a heavy rig on legs over a glowing bore, with a tall tower.
|
||||
7: {
|
||||
color: "#ff5a3c",
|
||||
size: 1,
|
||||
draw(a, t) {
|
||||
const c = a.color;
|
||||
// the bore under the rig
|
||||
a.disc(0, 0, 0, 52, c, `opacity="0.75" filter="url(#blur)"`);
|
||||
a.disc(0, 0, 0, 30, "#ffd0a0", `opacity="0.85" filter="url(#blur)"`);
|
||||
a.disc(0, 0, 0, 44, "none", `stroke="${shade(c, 0.2)}" stroke-width="3"`);
|
||||
// legs
|
||||
for (const [x, y] of [[-80, -80], [80, -80], [-80, 80]]) a.cbox(x, y, 0, 14, 14, 56, IRON);
|
||||
// drill string into the bore
|
||||
a.cyl(0, 0, -4, 8, 60, shade(HULL, -0.2));
|
||||
// deck
|
||||
a.box(-92, -92, 56, 184, 184, 16, HULL);
|
||||
a.stripes(-92, -92, 56, 184, 184, 16, 12, c);
|
||||
// tanks and a crane
|
||||
a.cyl(-60, 60, 72, 18, 40, HULL);
|
||||
a.disc(-60, 60, 96, 18.5, "none", `stroke="${c}" stroke-width="2.5"`);
|
||||
if (t >= 2) {
|
||||
a.cyl(60, -60, 72, 16, 34, HULL);
|
||||
a.disc(60, -60, 92, 16.5, "none", `stroke="${c}" stroke-width="2.5"`);
|
||||
}
|
||||
a.box(30, 30, 72, 52, 44, 40 + (t >= 3 ? 14 : 0), HULL);
|
||||
a.poly([P(82, 38, 92), P(82, 66, 92), P(82, 66, 104), P(82, 38, 104)], mix(c, "#1a0604", 0.4));
|
||||
const top = 260 + t * 12;
|
||||
a.lattice(0, 0, 72, top, 36, 12, shade(HULL, -0.1), 4, 6);
|
||||
a.cbox(0, 0, top, 30, 30, 12, c);
|
||||
a.light(P(0, 0, top + 20), 4, 0.9 + t * 0.1);
|
||||
a.line(P(-70, -40, 72), P(-110, 10, 170 + t * 6), shade(HULL, -0.2), 5);
|
||||
a.line(P(-110, 10, 170 + t * 6), P(-110, 10, 120), IRON, 1.5);
|
||||
a.cbox(-110, 10, 108, 10, 10, 12, c);
|
||||
if (t >= 4) for (const z of [140, 200]) a.disc(0, 0, z, 26, "none", `stroke="${c}" stroke-width="2.5" opacity="0.9"`);
|
||||
// the front leg last
|
||||
a.cbox(80, 80, 0, 14, 14, 56, IRON);
|
||||
a.light(P(92, 92, 72), 3, 0.9);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ scene
|
||||
|
||||
function svg(id, tier, maxTier) {
|
||||
const tool = TOOLS[id];
|
||||
const c = tool.color;
|
||||
const gx = 256, gy = 338; // ground origin on the image; deploy cards crop to about y 51..392
|
||||
// Fit the top tier into the frame (up to y 72, 440 wide); lower tiers are smaller, and
|
||||
// smaller tools stay smaller (tool.size).
|
||||
BB = { x0: 0, x1: 0, y0: 0, y1: 0 };
|
||||
tool.draw(new Art(c), maxTier);
|
||||
const box = BB;
|
||||
BB = null;
|
||||
const fit = Math.min((gy - 72) / -box.y0, 440 / (box.x1 - box.x0), 1.8);
|
||||
const a = new Art(c);
|
||||
tool.draw(a, tier);
|
||||
const k = maxTier ? tier / maxTier : 0;
|
||||
const scale = fit * tool.size * (0.8 + 0.2 * k);
|
||||
const shift = f(-((box.x0 + box.x1) / 2) * scale);
|
||||
|
||||
// The ground disc, rocks, and a ring of tier segments around the base.
|
||||
const r = rng(id * 31 + 7);
|
||||
let rocks = "";
|
||||
for (let i = 0; i < 26; i++) {
|
||||
const ang = r() * Math.PI * 2, d = 0.25 + r() * 0.75;
|
||||
const x = f(Math.cos(ang) * 190 * d), y = f(Math.sin(ang) * 50 * d);
|
||||
const s = f(2 + r() * 6);
|
||||
rocks += `<ellipse cx="${x}" cy="${y}" rx="${s}" ry="${f(s * 0.6)}" fill="#2a3446" opacity="${f(0.5 + r() * 0.5)}"/>`;
|
||||
}
|
||||
const rx = 168, ry = 44;
|
||||
const circ = Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry)));
|
||||
const seg = circ / Math.max(maxTier, 1);
|
||||
const lit = Array.from({ length: maxTier }, (_, i) => (i < tier ? `${f(seg * 0.82)} ${f(seg * 0.18)}` : `0 ${f(seg)}`)).join(" ");
|
||||
const ring = `<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${c}" stroke-opacity="0.18" stroke-width="3" stroke-dasharray="${f(seg * 0.82)} ${f(seg * 0.18)}" transform="rotate(180)"/>`
|
||||
+ (tier ? `<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${c}" stroke-width="7" opacity="0.6" filter="url(#blur)" stroke-dasharray="${lit}" transform="rotate(180)"/>
|
||||
<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${mix(c, "#ffffff", 0.35)}" stroke-width="3" stroke-dasharray="${lit}" transform="rotate(180)"/>` : "");
|
||||
|
||||
// Sparkles at the top tier.
|
||||
let sparkles = "";
|
||||
if (tier === maxTier && maxTier > 0) {
|
||||
const s = rng(id * 13);
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const x = f(80 + s() * 352), y = f(70 + s() * 240), q = f(2 + s() * 4);
|
||||
sparkles += `<path d="M${x},${y - q * 2} L${x + q * 0.4},${y} L${x},${y + q * 2} L${x - q * 0.4},${y} Z M${x - q * 2},${y} L${x},${y + q * 0.4} L${x + q * 2},${y} L${x},${y - q * 0.4} Z" fill="${mix(c, "#ffffff", 0.6)}" opacity="${f(0.4 + s() * 0.6)}"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="0.5" cy="0.45" r="0.75"><stop offset="0" stop-color="#0e2446"/><stop offset="1" stop-color="#040a16"/></radialGradient>
|
||||
<radialGradient id="aura" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="${c}" stop-opacity="${f((0.16 + 0.3 * k) * 100) / 100}"/><stop offset="1" stop-color="${c}" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="ground" cx="0.5" cy="0.4" r="0.6"><stop offset="0" stop-color="#1c2638"/><stop offset="0.75" stop-color="#121a28"/><stop offset="1" stop-color="#0a101c" stop-opacity="0"/></radialGradient>
|
||||
<linearGradient id="beam" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${c}" stop-opacity="0.9"/><stop offset="1" stop-color="${c}" stop-opacity="0.15"/></linearGradient>
|
||||
<radialGradient id="orb" cx="0.35" cy="0.35" r="0.7"><stop offset="0" stop-color="#ffffff"/><stop offset="0.4" stop-color="${mix(c, "#ffffff", 0.4)}"/><stop offset="1" stop-color="${shade(c, -0.5)}"/></radialGradient>
|
||||
<filter id="blur" x="-1" y="-1" width="3" height="3"><feGaussianBlur stdDeviation="6"/></filter>
|
||||
<filter id="halo" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="${f(2 + 4 * k)}" result="b"/><feFlood flood-color="${c}" flood-opacity="${f((0.25 + 0.5 * k) * 100) / 100}"/><feComposite in2="b" operator="in"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
${[...(a.defs?.values() ?? [])].join("\n ")}
|
||||
</defs>
|
||||
<rect width="${SIZE}" height="${SIZE}" fill="url(#bg)"/>
|
||||
<g stroke="#5fd4ff" stroke-opacity="0.06" stroke-width="1">${Array.from({ length: 15 }, (_, i) => `<line x1="${i * 36 + 4}" y1="0" x2="${i * 36 + 4}" y2="${SIZE}"/><line x1="0" y1="${i * 36 + 4}" x2="${SIZE}" y2="${i * 36 + 4}"/>`).join("")}</g>
|
||||
<ellipse cx="${gx}" cy="${gy - 110}" rx="${f(150 + 60 * k)}" ry="${f(150 + 60 * k)}" fill="url(#aura)"/>
|
||||
<g transform="translate(${gx} ${gy})">
|
||||
<ellipse rx="230" ry="70" fill="url(#ground)"/>
|
||||
${rocks}
|
||||
${ring}
|
||||
<ellipse rx="${f(110 * scale)}" ry="${f(38 * scale)}" fill="#000" opacity="0.45" filter="url(#blur)"/>
|
||||
<g transform="translate(${shift} 0) scale(${f(scale * 1000) / 1000})" filter="url(#halo)">
|
||||
${a.out.join("\n ")}
|
||||
</g>
|
||||
</g>
|
||||
${sparkles}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ main
|
||||
|
||||
export function tierImage(image, tier) {
|
||||
if (!tier) return image;
|
||||
const dot = image.lastIndexOf(".");
|
||||
return `${image.slice(0, dot)}-t${tier}${image.slice(dot)}`;
|
||||
}
|
||||
|
||||
const game = JSON.parse(readFileSync("config/game.json", "utf8"));
|
||||
const svgDir = process.argv.indexOf("--svg") > 0 ? process.argv[process.argv.indexOf("--svg") + 1] : null;
|
||||
const tmp = mkdtempSync(join(tmpdir(), "tool-art-"));
|
||||
if (svgDir) mkdirSync(svgDir, { recursive: true });
|
||||
try {
|
||||
for (const item of game.storeItems) {
|
||||
if (!TOOLS[item.id]) {
|
||||
console.warn(`no art for store item ${item.id} (${item.name}), skipped`);
|
||||
continue;
|
||||
}
|
||||
const maxTier = item.upgrades?.length ?? 0;
|
||||
for (let tier = 0; tier <= maxTier; tier++) {
|
||||
const name = tierImage(item.image, tier);
|
||||
const src = join(svgDir ?? tmp, name.replace(/\.\w+$/, ".svg"));
|
||||
writeFileSync(src, svg(item.id, tier, maxTier));
|
||||
const png = join(tmp, "out.png");
|
||||
execFileSync("rsvg-convert", ["-w", String(SIZE), "-h", String(SIZE), "-o", png, src]);
|
||||
const dst = join(OUT, name);
|
||||
const opts = name.endsWith(".jpg") ? ["-strip", "-quality", "88", "-interlace", "Plane"] : ["-strip", "-colors", "256", "PNG8:" + dst];
|
||||
execFileSync("magick", [png, ...opts, ...(name.endsWith(".jpg") ? [dst] : [])]);
|
||||
console.log(dst);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
@@ -74,6 +74,8 @@ func serve(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/moon-settings", s.handleGetMoonSettings)
|
||||
mux.HandleFunc("POST /api/moon-settings", s.handleSaveMoonSettings)
|
||||
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")
|
||||
srv := &http.Server{
|
||||
@@ -336,21 +338,34 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "page", page)
|
||||
}
|
||||
|
||||
// handleAction runs a game action and re-renders the screen. The request carries the
|
||||
// current view (s, moon, well, tab), which stays unless the action moves on.
|
||||
func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID string) {
|
||||
ctx := r.Context()
|
||||
view := viewFromForm(r.FormValue)
|
||||
// actionResult is what runAction did, for the response (HTML regions or JSON).
|
||||
type actionResult struct {
|
||||
Action string // the action, "refused" (onboarding gate) or "new-game" (left to the caller)
|
||||
Err error // a GameError: the action was refused or broke a rule
|
||||
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 finished []string // moons a claim mined out
|
||||
var saleCredits float64 // what a market sale earned (the sell ticket's payout effect)
|
||||
var finished []string
|
||||
var saleCredits float64
|
||||
var err error
|
||||
|
||||
action := r.PathValue("action")
|
||||
// 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) {
|
||||
serverError(w, err)
|
||||
return
|
||||
if err = actionAllowed(ctx, s.db, userID, action, get("redeploy") == "1"); err != nil && !isGameError(err) {
|
||||
return actionResult{}, err
|
||||
}
|
||||
if err != nil {
|
||||
action = "refused"
|
||||
@@ -365,45 +380,45 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
|
||||
case "sell-moon":
|
||||
var name string
|
||||
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"}
|
||||
}
|
||||
case "sell-tool":
|
||||
var name string
|
||||
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))
|
||||
}
|
||||
case "sell-attachment":
|
||||
item := r.FormValue("item")
|
||||
item := get("item")
|
||||
var earned float64
|
||||
if earned, err = sellAttachment(ctx, s.db, userID, item); err == nil {
|
||||
message = fmt.Sprintf("Sold %s for %s", item, formatCredits(earned))
|
||||
}
|
||||
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 {
|
||||
message = "Tool purchased"
|
||||
}
|
||||
case "upgrade":
|
||||
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
|
||||
view.Drill = followTool(view.Drill, r.FormValue("tool"), tool)
|
||||
view.Drill = followTool(view.Drill, get("tool"), tool)
|
||||
}
|
||||
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"
|
||||
}
|
||||
case "claim":
|
||||
// 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.
|
||||
scope := ClaimScope{
|
||||
MoonID: r.FormValue("claim-moon"),
|
||||
WellID: r.FormValue("claim-well"),
|
||||
Redeploy: r.FormValue("redeploy") == "1",
|
||||
MoonID: get("claim-moon"),
|
||||
WellID: get("claim-well"),
|
||||
Redeploy: get("redeploy") == "1",
|
||||
}
|
||||
if id := r.FormValue("claim-wide"); id != "" {
|
||||
if id := get("claim-wide"); id != "" {
|
||||
scope.MoonID, scope.Wide = id, true
|
||||
}
|
||||
var res ClaimResult
|
||||
@@ -453,13 +468,13 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
|
||||
}
|
||||
case "redrill":
|
||||
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"))
|
||||
}
|
||||
case "sell":
|
||||
resource := r.FormValue("resource")
|
||||
all := r.FormValue("all") == "1"
|
||||
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
|
||||
resource := get("resource")
|
||||
all := get("all") == "1"
|
||||
amount, _ := strconv.ParseFloat(get("amount"), 64)
|
||||
var earned float64
|
||||
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))
|
||||
@@ -467,15 +482,15 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
|
||||
}
|
||||
case "refine":
|
||||
// Starts a batch: recipe (a refined item name), qty, or all=1 for as many as possible.
|
||||
recipe := r.FormValue("recipe")
|
||||
qty, _ := strconv.Atoi(r.FormValue("qty"))
|
||||
if qty, err = startRefine(ctx, s.db, userID, recipe, qty, r.FormValue("all") == "1"); err == nil {
|
||||
recipe := get("recipe")
|
||||
qty, _ := strconv.Atoi(get("qty"))
|
||||
if qty, err = startRefine(ctx, s.db, userID, recipe, qty, get("all") == "1"); err == nil {
|
||||
message = fmt.Sprintf("Refining %d %s", qty, recipe)
|
||||
}
|
||||
case "collect":
|
||||
// Collects one finished batch (job), or all of them.
|
||||
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 {
|
||||
message = fmt.Sprintf("Collected %d %s", done[0].Qty, done[0].Item)
|
||||
} else {
|
||||
@@ -488,14 +503,14 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
|
||||
message = fmt.Sprintf("Refinery slot %d online", n)
|
||||
}
|
||||
case "buy-attachment":
|
||||
item := r.FormValue("item")
|
||||
item := get("item")
|
||||
if err = buyAttachment(ctx, s.db, userID, item); err == nil {
|
||||
message = item + " added to your inventory"
|
||||
}
|
||||
case "equip", "unequip":
|
||||
// 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.
|
||||
item, tool := r.FormValue("item"), r.FormValue("to")
|
||||
item, tool := get("item"), get("to")
|
||||
var used string
|
||||
if action == "equip" {
|
||||
used, err = equipAttachment(ctx, s.db, userID, item, tool)
|
||||
@@ -508,7 +523,7 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
|
||||
view.Drill = followTool(view.Drill, tool, used)
|
||||
}
|
||||
case "hire":
|
||||
name := r.FormValue("advisor")
|
||||
name := get("advisor")
|
||||
if err = hireAdvisor(ctx, s.db, userID, name); err == nil {
|
||||
message = name + " hired"
|
||||
}
|
||||
@@ -518,30 +533,53 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
|
||||
message = fmt.Sprintf("Advisor seat %d added", n)
|
||||
}
|
||||
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) {
|
||||
http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
var newID string
|
||||
if newID, err = createUser(ctx, s.db, newPlayerName()); err != nil {
|
||||
newID, err := createUser(ctx, s.db, newPlayerName())
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
setUserCookie(w, newID)
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
toast := &Toast{Message: message, Kind: action, Finished: strings.Join(finished, ","), Credits: saleCredits}
|
||||
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)
|
||||
@@ -549,10 +587,8 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if t := state.tool(upgraded); t != nil && !toast.Error {
|
||||
toast.Message = state.upgradedText(*t)
|
||||
}
|
||||
page := state.page(view.resolve(state))
|
||||
toast := res.toast(state)
|
||||
page := state.page(res.View.resolve(state))
|
||||
if err := s.loadRanking(ctx, page); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
@@ -560,10 +596,10 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
|
||||
page.Banner = s.banner(ctx)
|
||||
page.OOB = true
|
||||
page.Toast = toast
|
||||
if boughtTool != "" {
|
||||
page.Modal = state.buySuggestions(boughtTool)
|
||||
if res.BoughtTool != "" {
|
||||
page.Modal = state.buySuggestions(res.BoughtTool)
|
||||
}
|
||||
if action == "buy-moon" && !toast.Error {
|
||||
if res.Action == "buy-moon" && !toast.Error {
|
||||
page.Reveal = page.moonReveal()
|
||||
}
|
||||
s.render(w, "regions", page)
|
||||
|
||||
@@ -125,6 +125,89 @@ POST :host/act/new-game
|
||||
# Leaderboard (JSON): [{"name", "balance"}], richest first
|
||||
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,
|
||||
# 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/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)
|
||||
POST :host/utils/load-config
|
||||
:headers
|
||||
|
||||
@@ -1387,7 +1387,12 @@ func (r StakeRow) Code() string {
|
||||
if r.Item == nil {
|
||||
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 {
|
||||
return "Tool"
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 44 KiB |
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
{{if .NextSeatPrice}}
|
||||
<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"
|
||||
{{if not .CanBuySeat}}disabled title="{{short $.S.Balance .NextSeatPrice}}"{{end}}>+ Seat · <span class="num accent">{{cur .NextSeatPrice}}</span></button>
|
||||
{{end}}
|
||||
@@ -33,7 +33,7 @@
|
||||
<p class="ready card-buy">On your board</p>
|
||||
{{else}}
|
||||
<button class="btn btn-primary card-buy" hx-post="/act/hire" hx-vals="{{vals "advisor" .Name}}"
|
||||
{{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 $.Advisors.HireWhy . $.S.Balance}}disabled title="{{.}}"{{end}}>Hire · <span class="num">{{cur .Price}}</span></button>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -335,9 +335,9 @@
|
||||
{{$d := $.V.ToDrill .ID}}
|
||||
<article class="card tool-card {{if .Selected}}is-selected{{end}}">
|
||||
{{if $open}}
|
||||
<a class="card-media" href="{{$d}}" hx-get="{{$d}}" aria-label="{{.Item.Name}}: attachments"><img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'"></a>
|
||||
<a class="card-media" href="{{$d}}" hx-get="{{$d}}" aria-label="{{.Item.Name}}: attachments"><img src="/static/assets/items/{{.Item.ImageAt .Tier}}" alt="" onerror="this.style.visibility='hidden'"></a>
|
||||
{{else}}
|
||||
<div class="card-media"><img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'"></div>
|
||||
<div class="card-media"><img src="/static/assets/items/{{.Item.ImageAt .Tier}}" alt="" onerror="this.style.visibility='hidden'"></div>
|
||||
{{end}}
|
||||
<div class="card-body">
|
||||
<h2>{{if $open}}<a href="{{$d}}" hx-get="{{$d}}" class="row-link">{{.Item.Name}}</a>{{else}}{{.Item.Name}}{{end}}{{if gt .Count 1}} <span class="muted num">×{{.Count}}</span>{{end}} <span class="tier-chip num" title="Tier">T{{.Tier}}/{{.MaxTier}}</span></h2>
|
||||
@@ -662,7 +662,7 @@
|
||||
{{range .Idle}}
|
||||
<button type="button" class="btn deploy-card{{if .Short}}{{if gt .Here 0.0}} is-short{{else}} is-spoken{{end}}{{end}}" hx-post="/act/stake" hx-vals="{{vals "tool" .ID "target" $.Well.ID}}" title="Deploy {{.Item.Name}} T{{.Tier}}{{range .Gear}} · {{.Name}}{{end}}">
|
||||
<span class="deploy-card-media">
|
||||
<img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'">
|
||||
<img src="/static/assets/items/{{.Item.ImageAt .Tier}}" alt="" onerror="this.style.visibility='hidden'">
|
||||
{{with .Gear}}<span class="deploy-card-pips pips">{{range .}}<i class="pip is-on"></i>{{end}}</span>{{end}}
|
||||
{{if gt .Count 1}}<span class="deploy-card-count num">×{{.Count}}</span>{{end}}
|
||||
</span>
|
||||
@@ -693,7 +693,7 @@
|
||||
<h3>Drills{{with .Queue}}{{if gt (len .) 1}} <span class="muted small">· {{len .}} share this well, oldest claims first</span>{{end}}{{end}}</h3>
|
||||
{{range .Queue}}
|
||||
<div class="line drill-line is-{{.State}}">
|
||||
<span class="drill-thumb">{{if .Item}}<img src="/static/assets/items/{{.Item.Image}}" alt="" onerror="this.style.visibility='hidden'">{{end}}</span>
|
||||
<span class="drill-thumb">{{if .Item}}<img src="/static/assets/items/{{.Item.ImageAt .Tier}}" alt="" onerror="this.style.visibility='hidden'">{{end}}</span>
|
||||
<span class="drill-main">
|
||||
<span class="drill-name">{{if .Item}}{{.Item.Name}}{{else}}Tool{{end}} <span class="muted">T{{.Tier}}</span>{{template "gear" .Gear}}</span>
|
||||
<span class="drill-meta small"><span class="muted"><span class="num">+{{money .Amount}}</span>/cycle{{with .ReachText}} · {{.}}{{end}}</span>
|
||||
@@ -813,7 +813,7 @@
|
||||
<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-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>
|
||||
</div>
|
||||
{{else}}
|
||||
@@ -825,7 +825,7 @@
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
{{end}}
|
||||
{{if .NextSlotPrice}}
|
||||
<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"
|
||||
{{if not .CanBuySlot}}disabled title="{{short $.S.Balance .NextSlotPrice}}"{{end}}>
|
||||
+ Slot {{.NextSlot}} · <span class="num accent">{{cur .NextSlotPrice}}</span></button>
|
||||
@@ -47,8 +47,7 @@
|
||||
<h2 class="refinery-label cap">Recipes</h2>
|
||||
<div class="cards">
|
||||
{{range .Recipes}}
|
||||
{{$why := ""}}
|
||||
{{if eq $rp.Free 0}}{{$why = "All slots are busy"}}{{else if eq .MaxQty 0}}{{$why = "Not enough inputs"}}{{end}}
|
||||
{{$why := $rp.Why .}}
|
||||
<article class="card recipe">
|
||||
<div class="card-body">
|
||||
<div class="recipe-head">
|
||||
|
||||