Compare commits

...
7 Commits
Author SHA1 Message Date
JosephFerano 25f315e8dc API: advisors bench (hired, not seated, in the web's order) and removed (hired but no longer in the config, no card) 2026-10-20 01:39:53 +07:00
JosephFerano 571075c5ff Rate limit: normalise client IPs (loopback as one, IPv4-mapped as IPv4) 2026-10-20 00:20:23 +07:00
JosephFerano fbde1b79ae API: view.aside, whether the web shows the aside 2026-10-20 00:19:42 +07:00
JosephFerano 4e7e2d010d API: well note, stake reach and market quote texts
Well rows get over, full, statusText and noteText (the web's well note);
stakes get reachText; market quotes get up/changeText and the pressure,
event boost and advisor texts the web shows (empty when not shown), so
clients don't word them.
2026-10-19 22:43:55 +07:00
JosephFerano 020d1f7197 Past moons: 10 a page
The Past tab loads the 10 most recently finished moons at a time
(loadPastPage: limit/offset in the query, plus a selected past moon from
another page); older pages via page= in the view URL (with list=past).
The web shows Newer/Older controls and the total on the tab; the API has
view.page, pastPage {page, pages, total, perPage} and takes page=.
2026-10-19 22:37:08 +07:00
JosephFerano 1fd1059aa7 New-moon reveal: Buy another
The reveal (web card and the API's reveal) carries the next moon's price
and whether it can be bought now (why: locked or credits). The web card
gets a Buy another button next to Start mining: it closes the reveal and
buys again (buy-moon), disabled when unaffordable, hidden while more
moons are locked.
2026-10-19 22:36:57 +07:00
JosephFerano a8bf63ffe4 Advisors: numbered seats, seat, unseat, swap and dismiss
Hired advisors sit in numbered seats (advisor_hire.seat, migration 016:
existing hires take seats in hiring order, once); only seated advisors
give their bonus. New actions seat-advisor, unseat-advisor, swap-advisors
and dismiss-advisor (nothing refunded), gated on advisors and logged.
The Advisors section shows the seats and the advisors without one; a
hired advisor opens a panel in the aside (adv= in the view URL). The API
advisors block carries seatList, freeSeat, each advisor's seat, seat
reasons and the dismiss dialog.
2026-10-19 22:36:11 +07:00
22 changed files with 1125 additions and 139 deletions

No files matched your search

+8 -4
View File
@@ -33,7 +33,9 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
owned for the moon price and well scale, and can't be sold.
owned for the moon price and well scale, and can't be sold. The Past tab loads them 10 at a
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
`loadView` (state, the past page, the resolved view).
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
@@ -96,8 +98,10 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
and unequip any time, they count from the next stake), store cards and the drill panel
(`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire and buy
seat rules, Advisors section. Tables `advisor_hire`, `users.advisor_seats`.
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
`all` for players from before it). New players see only the first moon purchase; deploying
@@ -140,7 +144,7 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–015):
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–016):
changes for existing ones, applied on every `migrate`, so each must be idempotent.
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
+278 -26
View File
@@ -1,9 +1,15 @@
package main
// Advisors: people a player hires once, for credits, who boost the whole operation (drill
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments). Each
// advisor takes a seat: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Tables advisor_hire and users.advisor_seats.
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments).
// Seats are numbered: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Hiring needs a free seat and seats the advisor in the lowest one. Only
// seated advisors give their bonus. A hired advisor can be unseated (kept on the payroll,
// no bonus), seated again in a free seat, swapped with another (seated or not) or
// dismissed. Dismissing refunds nothing (the price is a hiring fee, like seats and slots,
// which aren't refunded either); hiring them again costs the full price. Like attachments,
// changes count from the next action: running drills keep their bonus.
// Tables advisor_hire (seat null: not seated) and users.advisor_seats.
import (
"context"
@@ -64,39 +70,199 @@ func lockAdvisors(ctx context.Context, tx pgx.Tx, userID string) (int, error) {
return seats, err
}
// hireAdvisor hires an advisor into a free seat.
// AdvisorHire is a hired advisor and their seat (0: not seated).
type AdvisorHire struct {
Name string
Seat int
}
// lockHires locks the player's row (see lockAdvisors) and reads their hires.
func lockHires(ctx context.Context, tx pgx.Tx, userID string) (int, []AdvisorHire, error) {
seats, err := lockAdvisors(ctx, tx, userID)
if err != nil {
return 0, nil, err
}
rows, err := tx.Query(ctx, `
select advisor, coalesce(seat, 0) from advisor_hire
where user_id = $1 order by hired_at, advisor for update`, userID)
if err != nil {
return 0, nil, err
}
hires, err := pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire])
return seats, hires, err
}
func findHire(hires []AdvisorHire, name string) *AdvisorHire {
for i := range hires {
if hires[i].Name == name {
return &hires[i]
}
}
return nil
}
// freeSeat is the lowest empty seat, 0 if every seat is taken.
func freeSeat(seats int, hires []AdvisorHire) int {
for n := 1; n <= seats; n++ {
taken := false
for _, h := range hires {
taken = taken || h.Seat == n
}
if !taken {
return n
}
}
return 0
}
// hiredOne finds a hired advisor by name, or the error for one who isn't.
func hiredOne(hires []AdvisorHire, name string) (*AdvisorHire, error) {
if h := findHire(hires, name); h != nil {
return h, nil
}
if cfg().advisor(name) == nil {
return nil, gameErr("Unknown advisor")
}
return nil, gameErr("%s doesn't work for you", name)
}
// setSeat moves a hired advisor to a seat (0: unseated).
func setSeat(ctx context.Context, tx pgx.Tx, userID, name string, seat int) error {
_, err := tx.Exec(ctx, "update advisor_hire set seat = nullif($3, 0) where user_id = $1 and advisor = $2",
userID, name, seat)
return err
}
// hireAdvisor hires an advisor into the lowest free seat.
func hireAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
a := cfg().advisor(name)
if a == nil {
return gameErr("Unknown advisor")
}
return inTx(ctx, db, func(tx pgx.Tx) error {
seats, err := lockAdvisors(ctx, tx, userID)
seats, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
var hired int
var already bool
err = tx.QueryRow(ctx, `
select count(*), coalesce(bool_or(advisor = $2), false) from advisor_hire where user_id = $1`,
userID, a.Name).Scan(&hired, &already)
if err != nil {
return err
}
if already {
if findHire(hires, a.Name) != nil {
return gameErr("%s already works for you", a.Name)
}
if hired >= seats {
seat := freeSeat(seats, hires)
if seat == 0 {
return gameErr("All advisor seats are taken. Buy another seat first")
}
if err := debit(ctx, tx, userID, a.Price); err != nil {
return err
}
_, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor) values ($1, $2)", userID, a.Name)
_, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", userID, a.Name, seat)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price})
return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price, "seat": seat})
})
}
// seatAdvisor puts a hired advisor in an empty seat (moving them if they sit elsewhere).
func seatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string, seat int) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
seats, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if seat < 1 || seat > seats {
return gameErr("No such seat")
}
if h.Seat == seat {
return gameErr("%s already sits there", name)
}
for _, o := range hires {
if o.Seat == seat {
return gameErr("Seat %d is taken by %s", seat, o.Name)
}
}
if err := setSeat(ctx, tx, userID, name, seat); err != nil {
return err
}
return logEvent(ctx, tx, userID, "seat_advisor", map[string]any{"advisor": name, "seat": seat, "from": h.Seat})
})
}
// unseatAdvisor takes a seated advisor off their seat; they stay hired, without a bonus.
func unseatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if h.Seat == 0 {
return gameErr("%s isn't seated", name)
}
if err := setSeat(ctx, tx, userID, name, 0); err != nil {
return err
}
return logEvent(ctx, tx, userID, "unseat_advisor", map[string]any{"advisor": name, "seat": h.Seat})
})
}
// swapAdvisors exchanges two hired advisors' seats; one of them may be unseated (the
// other then gives up their seat to them).
func swapAdvisors(ctx context.Context, db *pgxpool.Pool, userID, name, other string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
a, err := hiredOne(hires, name)
if err != nil {
return err
}
b, err := hiredOne(hires, other)
if err != nil {
return err
}
if a.Name == b.Name {
return gameErr("Pick two different advisors")
}
if a.Seat == 0 && b.Seat == 0 {
return gameErr("Neither of them is seated")
}
// One at a time: the seats are unique.
for _, step := range []struct {
name string
seat int
}{{a.Name, 0}, {b.Name, a.Seat}, {a.Name, b.Seat}} {
if err := setSeat(ctx, tx, userID, step.name, step.seat); err != nil {
return err
}
}
return logEvent(ctx, tx, userID, "swap_advisors", map[string]any{"advisor": a.Name, "with": b.Name,
"seat": b.Seat, "withSeat": a.Seat})
})
}
// dismissAdvisor lets a hired advisor go, freeing their seat. Nothing is refunded.
func dismissAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, "delete from advisor_hire where user_id = $1 and advisor = $2", userID, name); err != nil {
return err
}
return logEvent(ctx, tx, userID, "dismiss_advisor", map[string]any{"advisor": name, "seat": h.Seat})
})
}
@@ -130,42 +296,120 @@ func buyAdvisorSeat(ctx context.Context, db *pgxpool.Pool, userID string) (int,
type AdvisorsPage struct {
Cards []AdvisorCard
Seats, Hired int
Seats int
Hired int // seats filled
Benched int // hired, not seated
SeatList []SeatSlot
Bench []AdvisorCard // hired, not seated
FreeSeat int // the lowest empty seat, 0 if none
MaxSeats int
NextSeat int // number of the next seat to buy
NextSeatPrice float64 // 0 at the cap
CanBuySeat bool
Bonuses []BonusRow // the combined advisor bonus, capped
Panel *AdvisorPanel // the selected hired advisor (View.Adv)
}
// SeatSlot is a seat and who sits in it (nil: empty).
type SeatSlot struct {
N int
Advisor *AdvisorCard
}
type AdvisorCard struct {
*Advisor
Hired bool
Seat int // 0: not seated (or not hired)
CanAfford bool
}
func (s *State) advisorsPage() *AdvisorsPage {
// AdvisorPanel is a hired advisor's details, with what can change: their seat, a swap or
// dismissing them.
type AdvisorPanel struct {
AdvisorCard
Moves []SeatSlot // every other seat: move there (empty) or swap with who sits there
Swaps []AdvisorCard // seated: the unseated advisors who can take their seat
Dismiss string // the dismiss dialog's question
}
func (s *State) advisorsPage(v View) *AdvisorsPage {
c := cfg()
p := &AdvisorsPage{Seats: s.AdvisorSeats, Hired: len(s.Advisors), MaxSeats: 1 + len(c.Advisors.SeatPrices),
p := &AdvisorsPage{Seats: s.AdvisorSeats, MaxSeats: 1 + len(c.Advisors.SeatPrices),
NextSeat: s.AdvisorSeats + 1,
Bonuses: s.BonusRows()}
if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) {
p.NextSeatPrice = c.Advisors.SeatPrices[n]
p.CanBuySeat = s.Balance >= p.NextSeatPrice
}
for i := range c.Advisors.Items {
a := &c.Advisors.Items[i]
card := func(a *Advisor) AdvisorCard {
card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price}
for _, h := range s.Advisors {
card.Hired = card.Hired || h == a.Name
for _, h := range s.Hires {
if h.Name == a.Name {
card.Hired, card.Seat = true, h.Seat
}
p.Cards = append(p.Cards, card)
}
return card
}
for i := range c.Advisors.Items {
p.Cards = append(p.Cards, card(&c.Advisors.Items[i]))
}
// Hired advisors, also those since removed from the config (no bonus).
hired := func(name string) AdvisorCard {
if a := c.advisor(name); a != nil {
return card(a)
}
return card(&Advisor{Name: name, Title: "Advisor"})
}
p.SeatList = make([]SeatSlot, s.AdvisorSeats)
for i := range p.SeatList {
p.SeatList[i].N = i + 1
}
for _, h := range s.Hires {
hc := hired(h.Name)
if h.Seat >= 1 && h.Seat <= len(p.SeatList) {
p.SeatList[h.Seat-1].Advisor = &hc
p.Hired++
} else {
p.Bench = append(p.Bench, hc)
p.Benched++
}
}
for _, slot := range p.SeatList {
if slot.Advisor == nil {
p.FreeSeat = slot.N
break
}
}
if v.Adv != "" && s.hire(v.Adv) != nil {
pc := hired(v.Adv)
panel := &AdvisorPanel{AdvisorCard: pc, Dismiss: dismissConfirm(pc)}
for _, slot := range p.SeatList {
if slot.N != pc.Seat {
panel.Moves = append(panel.Moves, slot)
}
}
if pc.Seat > 0 {
panel.Swaps = p.Bench
}
p.Panel = panel
}
return p
}
// hire finds a hired advisor by name.
func (s *State) hire(name string) *AdvisorHire { return findHire(s.Hires, name) }
// dismissConfirm is the question before dismissing an advisor.
func dismissConfirm(c AdvisorCard) string {
if c.Price > 0 {
return fmt.Sprintf("Dismiss %s? The hiring fee isn't refunded: hiring them again costs %s.",
c.Name, formatCredits(c.Price))
}
return fmt.Sprintf("Dismiss %s?", c.Name)
}
// SeatFree reports whether the player has an empty advisor seat.
func (p *AdvisorsPage) SeatFree() bool { return p.Hired < p.Seats }
func (p *AdvisorsPage) SeatFree() bool { return p.FreeSeat > 0 }
// SeatConfirm is the question before buying the next seat.
func (p *AdvisorsPage) SeatConfirm() string {
@@ -184,3 +428,11 @@ func (p *AdvisorsPage) HireWhy(c AdvisorCard, balance float64) string {
}
return ""
}
// SeatWhy is why an unseated advisor can't take a seat now, "" if they can.
func (p *AdvisorsPage) SeatWhy() string {
if p.SeatFree() {
return ""
}
return "All seats taken. Unseat someone or buy another seat"
}
+56 -9
View File
@@ -40,7 +40,8 @@ type APIState struct {
Idle int `json:"idle"` // tools not mining
NextEnd *int64 `json:"nextEnd"`
Moons []APIMoonListItem `json:"moons"` // active moons
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first: one page (page=)
PastPage APIPastPage `json:"pastPage"`
Tools []APITool `json:"tools"`
Stakes []APIStake `json:"stakes"` // every running or unclaimed drill
Moon *APIMoon `json:"moon"` // s=moons with a moon
@@ -75,6 +76,17 @@ type APIView struct {
Past bool `json:"past"`
Pick string `json:"pick"`
Drill string `json:"drill"`
Page int `json:"page"` // the page of past moons (from 1)
Adv string `json:"adv"` // the selected hired advisor (advisors section)
Aside bool `json:"aside"` // the web shows the aside (viewport and side panel)
}
// APIPastPage is the page of past moons in past: request others with page=<n>.
type APIPastPage struct {
Page int `json:"page"` // from 1, most recently finished first
Pages int `json:"pages"` // at least 1
Total int `json:"total"` // past moons in all
PerPage int `json:"perPage"`
}
type APIAmount struct {
@@ -143,6 +155,8 @@ type APIStake struct {
Secs int `json:"durationSecs"`
Ready bool `json:"ready"`
Take float64 `json:"take"` // what claiming it would get now
// The web's words for its reach: "Mines 3 wells", "Strips the whole moon", "" for one.
ReachText string `json:"reachText"`
Covers []string `json:"covers,omitempty"` // multi-well drills: the wells it mines
// Moon detail only (projection, claims oldest first): what it gets in all and from
// this well, and whether that is its full amount (ok, short, none).
@@ -186,6 +200,14 @@ type APIWellRow struct {
Remain float64 `json:"remain"`
DryAfter int `json:"dryAfter"` // the claim that empties it (0: none)
DryAt *int64 `json:"dryAt"`
// Shared supply, as the web's well note: over (drills want more than it holds), full
// (they'll drain it), statusText the note's lead ("Over-drilled — next claims will come
// up short", "Fully committed" or ""), noteText the rest ("47 left · 2 committed · 45
// free after", "" with no demand).
Over bool `json:"over"`
Full bool `json:"full"`
StatusText string `json:"statusText"`
NoteText string `json:"noteText"`
}
type APIWell struct {
@@ -251,6 +273,18 @@ type APIQuote struct {
Base float64 `json:"base"`
SellAll float64 `json:"sellAll"`
Spark []float64 `json:"spark,omitempty"` // s=market: 24h, every 3rd point (15 minutes) and now
// The web's texts: up (change >= 0, green ▲), changeText "+1.25%"/"−0.50%"; pressed
// (selling pressure lowers the price noticeably), pressureText "−4%"; boosted (a live
// event), boostText "×2"; advised (an advisor sell bonus), advisorText "+8%". Each
// text is "" when its flag is false.
Up bool `json:"up"`
ChangeText string `json:"changeText"`
Pressed bool `json:"pressed"`
PressureText string `json:"pressureText"`
Boosted bool `json:"boosted"`
BoostText string `json:"boostText"`
Advised bool `json:"advised"`
AdvisorText string `json:"advisorText"`
}
// APIBanner is a banner line: an announcement or a live event (id matches liveEvents[].id).
@@ -297,7 +331,7 @@ func apiGear(gear []*Attachment) []string {
func apiStake(st StakeRow, projected bool) APIStake {
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers}
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers, ReachText: st.ReachText()}
if projected {
gets, here := st.Gets, st.Here
a.Gets, a.Here, a.State, a.Fate = &gets, &here, st.State, st.Fate()
@@ -336,7 +370,9 @@ func apiMoonList(rows []MoonListRow) []APIMoonListItem {
func apiState(p *Page, extra ...*Page) *APIState {
s, v := p.S, p.V
a := &APIState{UserID: s.UserID, Now: s.Now.UnixMilli(),
View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill},
View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill,
Page: v.Page, Adv: v.Adv, Aside: p.Scene.Select != nil},
PastPage: APIPastPage{Page: s.PastPage, Pages: s.pastPages(), Total: s.PastTotal, PerPage: pastPerPage},
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd),
Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{},
@@ -387,7 +423,8 @@ func apiState(p *Page, extra ...*Page) *APIState {
for _, w := range d.Wells {
row := APIWellRow{APIWellSupply: APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply},
APIDrillGroup: apiGroup(w.DrillGroup), Wide: []string{}, Selected: w.Selected, Redrill: w.Redrill,
Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt)}
Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt),
Over: w.Over(), Full: w.Full(), StatusText: w.StatusText(), NoteText: w.NoteText()}
for _, st := range w.Wide {
row.Wide = append(row.Wide, st.ID)
}
@@ -502,7 +539,17 @@ func apiMarket(trade *MarketPage, full bool) *APIMarket {
aq := APIQuote{Item: q.Resource, Refined: q.Refined, Balance: q.Balance,
Price: q.Price, Rate: q.Rate, Boost: q.Boost, Advisor: q.Advisor, Pressure: q.Pressure, Factor: q.Factor,
Open: q.Open, Change: q.Change, High: q.High, Low: q.Low, Value: q.Value,
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base()}
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base(),
Up: q.Up(), ChangeText: q.ChangeText(), Pressed: q.Pressed(), Boosted: q.Boosted(), Advised: q.Advised()}
if aq.Pressed {
aq.PressureText = q.PressureText()
}
if aq.Boosted {
aq.BoostText = q.BoostText()
}
if aq.Advised {
aq.AdvisorText = q.AdvisorText()
}
// A sale is priced at the pressure factor averaged over what it adds (sell in game.go).
aq.SellAll = q.Balance * aq.Unpressed * c.MarketPressure.Average(q.Pressure, q.Balance*aq.Base)
if full {
@@ -600,11 +647,11 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
// apiPage loads the player's state and builds the state for a view. get reads the
// request's include= and catalog= (see apiStateWith).
func (s *server) apiPage(ctx context.Context, userID string, v View, get func(string) string) (*APIState, error) {
state, err := loadState(ctx, s.db, userID)
state, v, err := loadView(ctx, s.db, userID, v)
if err != nil {
return nil, err
}
page, err := s.apiPageFor(ctx, state, v.resolve(state))
page, err := s.apiPageFor(ctx, state, v)
if err != nil {
return nil, err
}
@@ -736,13 +783,13 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
created, res.View, res.Message = true, View{}, "New game started"
}
state, err := loadState(ctx, s.db, userID)
state, v, err := loadView(ctx, s.db, userID, res.View)
if err != nil {
apiServerError(w, err)
return
}
toast := res.toast(state)
page, err := s.apiPageFor(ctx, state, res.View.resolve(state))
page, err := s.apiPageFor(ctx, state, v)
if err != nil {
apiServerError(w, err)
return
+57 -24
View File
@@ -27,7 +27,9 @@ Banned players get `403`.
Query params (all optional, same as the web URL): `s` (section: `moons`, `equipment`,
`market`, `store`, `refinery`, `advisors`, `leaderboard`, `help`, `new-moon`), `moon`,
`well`, `list=past` (show a mined-out moon), `pick` (market: selected quote), `drill`.
`well`, `list=past` (show a mined-out moon), `page` (the page of `past`, from 1: 10 past
moons a page, most recently finished first; out-of-range pages are clamped), `pick` (market:
selected quote), `drill`, `adv` (advisors: the hired advisor whose panel the web shows).
`include` (comma-separated sections, or `all`): section blocks to add besides the one for
`s` (see [Sections](#sections)); locked ones are skipped. Invalid or locked values fall back
like the web (`state.view` is the resolved view). Opening a revealed section with `s=`
@@ -70,8 +72,12 @@ The other sections' actions:
| `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
| `collect` | `job` (job id), or none for every finished batch | |
| `buy-slot` | – | refinery slot |
| `hire` | `advisor` (name) | needs a free seat |
| `hire` | `advisor` (name) | needs a free seat; takes the lowest one (`freeSeat`) |
| `buy-seat` | – | advisor seat |
| `seat-advisor` | `advisor`, `seat` (from 1) | a hired advisor into an empty seat (moves them if seated) |
| `unseat-advisor` | `advisor` | off their seat: stays hired, no bonus |
| `swap-advisors` | `advisor`, `with` (names) | exchange seats; one of them may be unseated |
| `dismiss-advisor` | `advisor` | frees their seat; nothing is refunded (confirm with `dismiss`) |
| `sell-moon` | `sell-moon` (moon id) | `moon.sale.canSell`; view moves to another moon |
| `new-game` | – | creates a new player and returns its id and state |
@@ -98,19 +104,20 @@ session; the web page also reads it once per page load.
| key | |
|---|---|
| `userId`, `now` | |
| `view` | `{section, moon, well, past, pick, drill}`, resolved |
| `view` | `{section, moon, well, past, page, pick, drill, adv, aside}`, resolved (`page` clamped, `adv` only a hired advisor in `s=advisors`); `aside`: the web shows the aside (viewport and side panel): moon view, new-moon teaser, an open drill or advisor panel |
| `credits` | balance |
| `resources`, `refined` | `[{name, amount}]` |
| `moonPrice` | price of the next moon |
| `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false |
| `shine` | revealed sections not opened yet |
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
| `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") |
| `pastPage` | `{page, pages, total, perPage}`: the page of past moons in `past` (`total` in all); ask for others with `page=` |
| `moons`, `past` | `past` is one page (`pastPage`). `[{id, name, kind, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18") |
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL; `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, 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` |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, reachText, start, end, durationSecs, ready, take, covers?}`; `reachText` the web's "Mines 3 wells" / "Strips the whole moon" (`""` for one well) |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; `chart` and `spark` only for `s=market` (or `include=market`) |
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `pressureText` "−4%" shown when `pressed` (recent selling lowers the price), `boostText` "×2" when `boosted` (live event), `advisorText` "+8%" when `advised` (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
| `banner` | `[{level, text, left, kind, id, end}]` announcements, then live events: `kind` `announcement` or `event`, `id` the announcement's or live event's id (an event's matches `liveEvents[].id`), `end` when it ends; `left` the web's "3h 20m left" at `now` (events only, else `""`) |
| `liveEvents` | active live events: `[{id, name, kind, resource, multiplier, start, end, headline}]`, `kind` `price_multiplier`, `cycle_multiplier` or `yield_multiplier` |
| `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
@@ -192,14 +199,31 @@ inputs"). `buySlot` is the buy-slot dialog (`null` at the cap), `buySlotWhyText`
### 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}]}
{seats, hired, benched, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, freeSeat, seatList: [{n, advisor}],
bench, bonuses, buySeat, buySeatWhyText, selected,
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, seat,
canAfford, canHire, why, whyText, canSeat, seatWhy, seatWhyText, dismiss, removed}]}
```
Seats are numbered from 1. Only seated advisors give their bonus (`bonuses`); a hired
advisor can be unseated and stays hired. Changes count from the next stake, sale or batch.
`hired` is the seats filled, `benched` the advisors hired but not seated, `seatList` every
seat with the name of who sits there (`""` empty), `bench` the names of the hired advisors
not seated in the web's order (hire order), `freeSeat` the lowest empty seat (0 if
none: where `hire` seats a new one). `selected` is the web's open panel (`view.adv`).
`buySeat` is the buy-seat dialog (`null` at the cap), `buySeatWhyText` why "+ Seat" is
disabled. An advisor's `why`: `hired` ("On your board"), `seats` ("All seats taken. Buy
another seat first"), `credits` ("Need ₥X more") or `""`.
disabled. An advisor's `why` (hiring): `hired` ("On your board"), `seats` ("All seats
taken. Buy another seat first"), `credits` ("Need ₥X more") or `""`. For a hired advisor:
`seat` (0: not seated), `dismiss` the dismiss-advisor dialog (`null` if not hired; nothing
is refunded, hiring them again costs `price`); unseated ones also `canSeat` (a seat is
free: `seat-advisor` with `seat=<freeSeat>`) and `seatWhy` `seats` ("All seats taken.
Unseat someone or buy another seat") or `""`. A hired advisor no longer in the config comes
last with `removed` (no bonus, no card on the web; it shows in its seat or the bench and can be
dismissed).
The web's panel for a hired advisor (any client may do the same): for every other seat,
`seat-advisor` to an empty one or `swap-advisors` with who sits there; when seated, also
`swap-advisors` with each unseated advisor and `unseat-advisor`; and `dismiss-advisor`.
### leaderboard (`s=leaderboard`)
@@ -221,12 +245,15 @@ balance after buying, `need` the credits missing, `whyText` why the button is di
```
{moonId, name, kind, description, rating, headline, sub,
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}]}
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}],
next: {price, canBuy, why, whyText}}
```
`headline`, `sub` and `traits` are the card's verdict text; `worth` its wells at market
prices, `multiple` worth / its price; a well bar is `supply / maxSupply`, `full` from
`fullAt`.
`fullAt`. `next` is the card's "Buy another" button (`buy-moon` again): the next moon's
price, `canBuy`, and `why` `locked` (more moons come with the store: the web hides the
button), `credits` ("Need ₥X more", the button disabled) or `""`.
### buyModal (in the `buy-tool` response)
@@ -257,7 +284,7 @@ is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkl
## Texts
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `newGame`): `{title, text, ok, danger}`,
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
the web's title, question, OK button and whether it's destructive, e.g.
`{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 (T0) for ₥750? Its
attachments go back to your inventory.", "ok": "Sell for ₥750", "danger": true}`. Texts with
@@ -266,11 +293,8 @@ a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.no
Not sent (the client words them from the numbers, like the web's templates):
- moon list status: "N drills", "Idle", "Tapped out"; "A well here has more drills than
supply" (`over`).
- well note: "Over-drilled — next claims will come up short", "Fully committed", "X left · Y
committed · runs dry after N claims" / "Z free after" (`demand`, `remain`, `dryAfter`).
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
current drills" (`well.idle[].short`, `here`, `free`).
- market: change ("+1.25%"), pressure ("−12%"), boost ("×2"), advisor ("+10%").
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
The web's formats:
@@ -317,7 +341,7 @@ Content-Type: application/json
"state": {
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
"now": 1792391815265,
"view": {"section": "moons", "moon": "54daa782-…", "well": "b9d643c4-…", "past": false, "pick": "", "drill": ""},
"view": {"section": "moons", "moon": "54daa782-…", "well": "b9d643c4-…", "past": false, "page": 1, "pick": "", "drill": "", "adv": "", "aside": true},
"credits": 1644.63,
"resources": [{"name": "Azurium", "amount": 0}, {"name": "Nebulance", "amount": 0}],
"refined": [],
@@ -331,6 +355,7 @@ Content-Type: application/json
"drills": 1, "ready": 0, "over": false, "selected": true, "createdAt": 1792391738240,
"finished": false, "finishedAt": null}],
"past": [],
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5,
"reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}],
@@ -408,12 +433,19 @@ From a live server (long ids and arrays shortened).
`s=advisors`, `state.advisors`:
```json
{"seats": 1, "hired": 0, "maxSeats": 4, "nextSeat": 2, "nextSeatPrice": 10000, "canBuySeat": true, "seatFree": true,
"bonuses": [],
{"seats": 2, "hired": 1, "benched": 1, "maxSeats": 4, "nextSeat": 3, "nextSeatPrice": 40000, "canBuySeat": true,
"seatFree": true, "freeSeat": 2, "seatList": [{"n": 1, "advisor": "Ada Voss"}, {"n": 2, "advisor": ""}], "bench": ["Mira Castellanos"],
"bonuses": [{"kind": "speed", "mult": 1.1, "capped": false, "label": "Drill speed", "text": "+10%"}], "selected": "",
"buySeat": {"title": "New seat", "text": "Add advisor seat 3 for ₥40,000?", "ok": "Buy seat", "danger": false},
"buySeatWhyText": "",
"advisors": [{"name": "Ada Voss", "title": "Shift Foreman", "image": "/static/assets/advisors/ada-voss.png",
"description": "Runs the drill crews in tight shifts. No rig sits idle on her watch.", "price": 8000,
"effect": {"speed": 1.1, "yield": 1, "sell": 1, "refine": 1}, "hired": false, "canAfford": true,
"canHire": true}, "…"]}
"effect": {"speed": 1.1, "yield": 1, "sell": 1, "refine": 1}, "effectText": "+10% drill speed",
"hired": true, "seat": 1, "canAfford": true, "canHire": false, "why": "hired", "whyText": "On your board",
"canSeat": false, "seatWhy": "", "seatWhyText": "",
"dismiss": {"title": "Dismiss advisor", "text": "Dismiss Ada Voss? The hiring fee isn't refunded: hiring them again costs ₥8,000.",
"ok": "Dismiss", "danger": true}, "removed": false},
{"name": "Mira Castellanos", "…": "…", "hired": true, "seat": 0, "canSeat": true, "seatWhy": ""}, "…"]}
```
`s=leaderboard`: `{"players": 2, "top": [{"rank": 1, "name": "…", "credits": 73579.5, "me": true}, "…"], "you": null, "me": {"rank": 1, "credits": 73579.5}}`
@@ -432,7 +464,8 @@ From a live server (long ids and arrays shortened).
"rating": {"tier": "poor", "label": "Poor", "stars": 1, "pct": 12, "top": 88},
"headline": "Slim pickings", "sub": "Not much down there. Better luck next time.", "worth": 717, "multiple": 1.79,
"traits": [], "maxSupply": 90, "fullAt": 84,
"wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"]}
"wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"],
"next": {"price": 550, "canBuy": true, "why": "", "whyText": ""}}
```
`POST /api/act/buy-tool`, `buyModal`:
+84 -10
View File
@@ -726,16 +726,27 @@ func apiRefinery(p *Page) *APIRefinery {
type APIAdvisors struct {
Seats int `json:"seats"`
Hired int `json:"hired"`
Hired int `json:"hired"` // seats filled
Benched int `json:"benched"` // hired, not seated
MaxSeats int `json:"maxSeats"`
NextSeat int `json:"nextSeat"`
NextSeatPrice float64 `json:"nextSeatPrice"` // 0 at the cap
CanBuySeat bool `json:"canBuySeat"`
SeatFree bool `json:"seatFree"`
FreeSeat int `json:"freeSeat"` // the lowest empty seat, 0 if none
SeatList []APISeat `json:"seatList"` // every seat, from 1
Bench []string `json:"bench"` // hired, not seated, in the web's order (hire order)
Bonuses []APIBonusRow `json:"bonuses"`
Advisors []APIAdvisorUp `json:"advisors"`
BuySeat *APIConfirm `json:"buySeat"` // before buy-seat; null at the cap
BuySeatWhy string `json:"buySeatWhyText"` // why "+ Seat" is disabled, "" if it isn't
Selected string `json:"selected"` // the advisor of the web's panel (view.adv), "" if none
}
// APISeat is a seat and who sits in it ("" empty).
type APISeat struct {
N int `json:"n"`
Advisor string `json:"advisor"`
}
type APIAdvisorUp struct {
@@ -746,26 +757,48 @@ type APIAdvisorUp struct {
Price float64 `json:"price"`
Effect APIEffect `json:"effect"`
Hired bool `json:"hired"`
Seat int `json:"seat"` // hired: their seat, 0 if not seated (no bonus)
CanAfford bool `json:"canAfford"`
CanHire bool `json:"canHire"` // not hired, a seat free, affordable
EffectText string `json:"effectText"`
Why string `json:"why"` // hired, seats (none free), credits; "" can hire
WhyText string `json:"whyText"`
// Hired and not seated: whether seat-advisor can seat them now (in freeSeat);
// seatWhy "seats" (none free) or "".
CanSeat bool `json:"canSeat"`
SeatWhy string `json:"seatWhy"`
SeatWhyText string `json:"seatWhyText"`
Dismiss *APIConfirm `json:"dismiss"` // hired: the dismiss-advisor dialog; null otherwise
Removed bool `json:"removed"` // hired but no longer in the config: no card on the web
}
func apiAdvisors(p *Page) *APIAdvisors {
ap := p.Advisors
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(),
Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}}
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, Benched: ap.Benched, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(), FreeSeat: ap.FreeSeat,
SeatList: []APISeat{}, Bench: []string{}, Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}, Selected: p.V.Adv}
for _, slot := range ap.SeatList {
seat := APISeat{N: slot.N}
if slot.Advisor != nil {
seat.Advisor = slot.Advisor.Name
}
a.SeatList = append(a.SeatList, seat)
}
for _, c := range ap.Bench {
a.Bench = append(a.Bench, c.Name)
}
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()}
up := func(c AdvisorCard) APIAdvisorUp {
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Description: c.Description, Price: c.Price,
Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)),
Hired: c.Hired, Seat: c.Seat, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(),
EffectText: c.Advisor.Effect()}
if c.Image != "" {
up.Image = advisorImages + c.Image
}
switch {
case c.Hired:
up.Why, up.WhyText = "hired", "On your board"
@@ -774,11 +807,42 @@ func apiAdvisors(p *Page) *APIAdvisors {
case !c.CanAfford:
up.Why, up.WhyText = "credits", ap.HireWhy(c, p.S.Balance)
}
a.Advisors = append(a.Advisors, up)
if c.Hired {
up.Dismiss = &APIConfirm{Title: "Dismiss advisor", Text: dismissConfirm(c), OK: "Dismiss", Danger: true}
if c.Seat == 0 {
up.CanSeat = ap.SeatFree()
if !up.CanSeat {
up.SeatWhy, up.SeatWhyText = "seats", ap.SeatWhy()
}
}
}
return up
}
for _, c := range ap.Cards {
a.Advisors = append(a.Advisors, up(c))
}
// Hired advisors no longer in the config (no bonus) come last.
for _, c := range append(seated(ap.SeatList), ap.Bench...) {
if cfg().advisor(c.Name) == nil {
r := up(c)
r.Removed = true
a.Advisors = append(a.Advisors, r)
}
}
return a
}
// seated lists the advisors in the seats.
func seated(seats []SeatSlot) []AdvisorCard {
var out []AdvisorCard
for _, slot := range seats {
if slot.Advisor != nil {
out = append(out, *slot.Advisor)
}
}
return out
}
// ------------------------------------------------------------------ leaderboard, help, new moon
type APILeaderboard struct {
@@ -878,6 +942,15 @@ type APIReveal struct {
MaxSupply float64 `json:"maxSupply"` // a full bar
FullAt float64 `json:"fullAt"` // wells from here count as full
Wells []APIRevealWell `json:"wells"`
Next APINextMoon `json:"next"` // "Buy another": buy-moon again
}
// APINextMoon is the reveal card's "Buy another" button.
type APINextMoon struct {
Price float64 `json:"price"` // the next moon's price
CanBuy bool `json:"canBuy"` // affordable and available
Why string `json:"why"` // locked (hide the button), credits; "" can buy
WhyText string `json:"whyText"`
}
type APIRevealWell struct {
@@ -894,7 +967,8 @@ func apiReveal(r *MoonReveal) *APIReveal {
a := &APIReveal{MoonID: r.ID, Name: r.Name, Kind: r.Kind, Description: r.Description,
Rating: *apiRating(&r.Rating),
Headline: r.Headline, Sub: r.Sub, Worth: r.Worth, Multiple: r.Multiple, Traits: []string{}, MaxSupply: r.MaxSupply,
FullAt: r.FullAt, Wells: []APIRevealWell{}}
FullAt: r.FullAt, Wells: []APIRevealWell{},
Next: APINextMoon{Price: r.Next.Price, CanBuy: r.Next.CanBuy, Why: r.Next.Why, WhyText: r.Next.WhyText}}
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})
+41 -4
View File
@@ -11,8 +11,10 @@ import (
func TestAPI(t *testing.T) {
unlocked := newPlayerState()
unlocked.Onboarding = []string{"all"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} {
asides := map[bool]bool{}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
p := s.page(v.resolve(s))
testRanking(p)
a := apiState(p)
@@ -52,7 +54,8 @@ func TestAPI(t *testing.T) {
}
if p.Moon != nil {
// The reveal card and the buy modal, as an action response maps them.
if r := apiReveal(p.moonReveal()); r == nil || len(r.Wells) != len(p.Moon.Wells) || r.Headline == "" {
if r := apiReveal(p.moonReveal()); r == nil || len(r.Wells) != len(p.Moon.Wells) || r.Headline == "" ||
r.Next.Price != s.MoonPrice() || r.Next.CanBuy != (r.Next.Why == "") || (r.Next.Why == "") != (r.Next.WhyText == "") {
t.Errorf("view %+v: reveal %+v", v, r)
}
}
@@ -80,6 +83,13 @@ func TestAPI(t *testing.T) {
if _, err := json.Marshal(all); err != nil {
t.Fatalf("marshal %+v with every section: %v", v, err)
}
// The aside: the moon view, the new-moon teaser, an open drill or advisor panel.
aside := p.Moon != nil || p.V.Section == "new-moon" || p.Drill != nil ||
p.Advisors != nil && p.Advisors.Panel != nil
if a.View.Aside != aside || aside != (p.Scene.Select != nil) {
t.Errorf("view %+v: aside %v, want %v", p.V, a.View.Aside, aside)
}
asides[a.View.Aside] = true
if a.View.Section != p.V.Section || a.View.Moon != p.V.Moon || a.View.Well != p.V.Well {
t.Errorf("view %+v: state has %+v", p.V, a.View)
}
@@ -94,11 +104,18 @@ func TestAPI(t *testing.T) {
if (a.Moon != nil) != (p.Moon != nil) || (a.Well != nil) != (p.Well != nil) {
t.Errorf("view %+v: moon or well missing", v)
}
if pp := a.PastPage; len(a.Past) > pp.PerPage || pp.Page < 1 || pp.Page > pp.Pages || pp.Total != s.PastTotal ||
a.View.Page != pp.Page || pp.Page < pp.Pages && len(a.Past) != pp.PerPage {
t.Errorf("view %+v: past page %+v with %d moons", v, pp, len(a.Past))
}
if (a.Market != nil) != s.Unlocked("market") {
t.Errorf("view %+v: market is %v, unlocked %v", v, a.Market != nil, s.Unlocked("market"))
}
}
}
if !asides[true] || !asides[false] {
t.Errorf("views with and without the aside: %v", asides)
}
a := apiState(newPlayerState().page(View{}.resolve(newPlayerState())))
if a.Unlocked["market"] || !a.Unlocked["new-moon"] || a.View.Section != "new-moon" {
t.Errorf("new player: unlocked %v, section %q", a.Unlocked, a.View.Section)
@@ -140,8 +157,11 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
if len(w.Drills) > 0 && w.Label == "" {
fail("well label", w)
}
if (w.Over || w.Full) != (w.StatusText != "") || (w.Demand > 0) != (w.NoteText != "") {
fail("well note", w)
}
for _, d := range w.Drills {
if d.Fate == "" {
if d.Fate == "" || (d.Reach > 1) != (d.ReachText != "") {
fail("drill fate", d)
}
}
@@ -150,6 +170,13 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
fail("moon sale", sale)
}
}
if a.Market != nil {
for _, q := range a.Market.Quotes {
if q.ChangeText == "" || q.Pressed != (q.PressureText != "") || q.Boosted != (q.BoostText != "") || q.Advised != (q.AdvisorText != "") {
fail("quote texts", q)
}
}
}
for _, b := range a.Bonuses {
if b.Label == "" || b.Text == "" {
fail("bonus", b)
@@ -207,6 +234,10 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
if (up.Why != "") != (up.WhyText != "") || up.Why == "" && !up.CanHire {
fail("advisor texts", up)
}
if up.Hired != (up.Dismiss != nil) || up.Dismiss != nil && up.Dismiss.Text == "" ||
(up.SeatWhy != "") != (up.SeatWhyText != "") || up.CanSeat && (up.Seat != 0 || ad.FreeSeat == 0) {
fail("advisor seat texts", up)
}
}
}
if h := a.Help; h != nil && h.NewGame.Text != newGameConfirm {
@@ -244,9 +275,15 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
t.Errorf("view %+v: refinery %+v", v, a.Refinery)
}
case "advisors":
if a.Advisors == nil || len(a.Advisors.Advisors) != len(cfg().Advisors.Items) {
if a.Advisors == nil || len(a.Advisors.Advisors) < len(cfg().Advisors.Items) || len(a.Advisors.SeatList) != s.AdvisorSeats ||
a.Advisors.Hired+a.Advisors.Benched != len(s.Hires) || a.Advisors.Selected != v.Adv || len(a.Advisors.Bench) != a.Advisors.Benched {
t.Errorf("view %+v: advisors %+v", v, a.Advisors)
}
for _, h := range s.Hires {
if h.Seat > 0 && a.Advisors.SeatList[h.Seat-1].Advisor != h.Name {
t.Errorf("view %+v: seat %d has %q, not %s", v, h.Seat, a.Advisors.SeatList[h.Seat-1].Advisor, h.Name)
}
}
case "leaderboard":
if a.Leaderboard == nil || a.Leaderboard.Me == nil || a.Leaderboard.Me.Rank != 12 {
t.Errorf("view %+v: leaderboard %+v", v, a.Leaderboard)
+13 -5
View File
@@ -114,12 +114,12 @@ func (cfg *GameConfig) validateBonuses() error {
// ------------------------------------------------------------------ in a transaction
// loadBonus reads the player's advisors and, with a tool id, the tool's attachments, and
// loadBonus reads the player's seated advisors and, with a tool id, the tool's attachments, and
// combines them. Callers that pass a tool have locked its row; the advisor rows are
// share-locked so a hire can't change them halfway through the action.
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
rows, err := tx.Query(ctx, `
select advisor from advisor_hire where user_id = $1 order by advisor for share`, userID)
select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
if err != nil {
return noBonus, err
}
@@ -143,20 +143,28 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, er
// ------------------------------------------------------------------ state
// loadBonusState loads the player's attachments, advisors and advisor seats into the state.
// loadBonusState loads the player's attachments, advisors (hired, and the seated ones) and
// advisor seats into the state.
func loadBonusState(ctx context.Context, db querier, s *State) error {
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
if err != nil {
return err
}
rows, err := db.Query(ctx, `
select advisor from advisor_hire where user_id = $1 order by hired_at, advisor`, s.UserID)
select advisor, coalesce(seat, 0) from advisor_hire where user_id = $1
order by seat nulls last, hired_at, advisor`, s.UserID)
if err != nil {
return err
}
if s.Advisors, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
if s.Hires, err = pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire]); err != nil {
return err
}
s.Advisors = nil
for _, h := range s.Hires {
if h.Seat > 0 {
s.Advisors = append(s.Advisors, h.Name)
}
}
rows, err = db.Query(ctx, `
select coalesce(inventory_item_id::text, ''), item from attachment
where user_id = $1 order by item`, s.UserID)
+96 -12
View File
@@ -113,6 +113,12 @@ type State struct {
Balance float64
Resources []ResourceBalance
Moons []Moon
// Past moons (finished): how many, the page shown (View.Page, from 1) and its moons,
// most recently finished first. Only those (and a selected past moon) have FinishedAt
// and Mined.
PastTotal int
PastPage int
PastIDs []string
Tools []Tool
Stakes []Stake
Now time.Time
@@ -130,10 +136,12 @@ type State struct {
Pressure map[string]float64 // the player's selling pressure by item, now (market.go)
// Bonuses (bonus.go): attachments equipped on each tool and spare ones (by kind),
// advisors hired, advisor seats owned.
// seated advisors (their bonus counts), every advisor hired with their seat, advisor
// seats owned.
Fitted map[string][]string
Spare map[string]int
Advisors []string
Hires []AdvisorHire // seated first, by seat
AdvisorSeats int
// Onboarding flags (onboarding.go): which parts of the game are revealed.
@@ -410,9 +418,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
for _, st := range s.Stakes {
delete(s.LastWell, st.ToolID)
}
if err := loadPastMoons(ctx, db, s); err != nil {
return nil, err
}
loadPastMoons(s)
if err := loadRefinery(ctx, db, s); err != nil {
return nil, err
}
@@ -434,14 +440,31 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
return s, nil
}
// loadPastMoons marks the finished moons and adds what was mined on each. Supply only goes
// down and every stake gets claimed, so a finished moon stays finished.
func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
// loadView loads the player's state for a view (with the page of past moons it shows) and
// resolves the view.
func loadView(ctx context.Context, db *pgxpool.Pool, userID string, v View) (*State, View, error) {
s, err := loadState(ctx, db, userID)
if err != nil {
return nil, v, err
}
if err := loadPastPage(ctx, db, s, v.Page, v.Moon); err != nil {
return nil, v, err
}
return s, v.resolve(s), nil
}
// pastPerPage is how many past moons the moon list shows at a time (View.Page).
const pastPerPage = 10
// loadPastMoons marks the finished moons and counts them. Supply only goes down and every
// stake gets claimed, so a finished moon stays finished. What was mined on them is loaded
// a page at a time (loadPastPage).
func loadPastMoons(s *State) {
busy := map[string]bool{}
for _, st := range s.Stakes {
busy[st.MoonID] = true
}
past := map[string]*Moon{}
s.PastTotal = 0
for i := range s.Moons {
m := &s.Moons[i]
m.Finished = len(m.Wells) > 0 && !busy[m.ID]
@@ -451,13 +474,51 @@ func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
}
}
if m.Finished {
past[m.ID] = m
s.PastTotal++
}
}
if len(past) == 0 {
}
// pastPages is how many pages of past moons there are (at least 1).
func (s *State) pastPages() int { return max(1, (s.PastTotal+pastPerPage-1)/pastPerPage) }
// loadPastPage loads one page of past moons, most recently finished first (page 1), and
// what was mined on each; also on the selected moon if it's a past one on another page.
// The page is clamped to the ones there are.
func loadPastPage(ctx context.Context, db *pgxpool.Pool, s *State, page int, selected string) error {
s.PastPage = min(max(page, 1), s.pastPages())
s.PastIDs = []string{}
past := map[string]*Moon{}
var ids []string
for i := range s.Moons {
if s.Moons[i].Finished {
past[s.Moons[i].ID] = &s.Moons[i]
ids = append(ids, s.Moons[i].ID)
}
}
if len(ids) == 0 {
return nil
}
rows, err := db.Query(ctx, `
select ids.id
from unnest($1::text[]) ids(id)
left join resource_well on resource_well.source_id::text = ids.id
left join staking_event on staking_event.well_id = resource_well.id
left join claim_event on claim_event.staking_event_id = staking_event.id
group by ids.id
order by max(claim_event.created_at) desc nulls last, ids.id
limit $2 offset $3`, ids, pastPerPage, (s.PastPage-1)*pastPerPage)
if err != nil {
return err
}
if s.PastIDs, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
return err
}
load := slices.Clone(s.PastIDs)
if past[selected] != nil && !slices.Contains(load, selected) {
load = append(load, selected)
}
rows, err = db.Query(ctx, `
select resource_well.source_id, resource.name,
sum(coalesce(claim_well.amount, claim_event.claim_amount)), max(claim_event.created_at)
from claim_event
@@ -465,9 +526,9 @@ func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
left join claim_well on claim_well.claim_event_id = claim_event.id
join resource_well on resource_well.id = coalesce(claim_well.well_id, staking_event.well_id)
join resource on resource.id = resource_well.resource_id
where staking_event.user_id = $1
where staking_event.user_id = $1 and resource_well.source_id::text = any($2)
group by resource_well.source_id, resource.name
order by resource.name`, s.UserID)
order by resource.name`, s.UserID, load)
if err != nil {
return err
}
@@ -486,6 +547,29 @@ func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
return err
}
// pagePastInMemory picks a page of past moons by FinishedAt, as loadPastPage does in SQL
// (for states built without a database).
func (s *State) pagePastInMemory(page int) {
s.PastTotal = 0
for _, m := range s.Moons {
if m.Finished {
s.PastTotal++
}
}
s.PastPage = min(max(page, 1), s.pastPages())
var past []*Moon
for i := range s.Moons {
if s.Moons[i].Finished {
past = append(past, &s.Moons[i])
}
}
slices.SortStableFunc(past, func(a, b *Moon) int { return b.FinishedAt.Compare(a.FinishedAt) })
s.PastIDs = []string{}
for _, m := range past[min(len(past), (s.PastPage-1)*pastPerPage):min(len(past), s.PastPage*pastPerPage)] {
s.PastIDs = append(s.PastIDs, m.ID)
}
}
// moonFinished reports whether a moon has just been mined out: its wells are empty and
// nothing on it is left to claim.
func moonFinished(ctx context.Context, tx pgx.Tx, moonID string) (bool, error) {
+2 -1
View File
@@ -141,7 +141,8 @@ var actionGates = map[string]string{
"equip": "store", "unequip": "store", "redrill": "market", "new-game": "help",
"sell": "market",
"refine": "refinery", "collect": "refinery", "buy-slot": "refinery",
"hire": "advisors", "buy-seat": "advisors",
"hire": "advisors", "buy-seat": "advisors", "seat-advisor": "advisors", "unseat-advisor": "advisors",
"swap-advisors": "advisors", "dismiss-advisor": "advisors",
"buy-moon": "new-moon",
}
+19 -1
View File
@@ -52,6 +52,17 @@ type MoonReveal struct {
Traits []string // notable traits and their rarity
MaxSupply float64 // the biggest well a moon can have: a full bar
FullAt float64 // wells from here count as full
Next NextMoon // buying another one right away
}
// NextMoon is the card's "Buy another": the next moon's price and whether the player can
// buy it now. Why: "locked" (more moons come with the store, onboarding.go), "credits" or
// "".
type NextMoon struct {
Price float64
CanBuy bool
Why string
WhyText string
}
var countWords = []string{"", "one", "two", "three", "four", "five", "six", "seven", "eight"}
@@ -68,7 +79,14 @@ func (p *Page) moonReveal() *MoonReveal {
for i, w := range p.Moon.Wells {
wells[i] = wellSupply{Resource: w.Resource, Supply: w.Supply}
}
r := &MoonReveal{MoonDetail: p.Moon}
r := &MoonReveal{MoonDetail: p.Moon, Next: NextMoon{Price: p.S.MoonPrice()}}
switch {
case !p.S.Unlocked("store"):
r.Next.Why, r.Next.WhyText = "locked", "Not available yet"
case p.S.Balance < r.Next.Price:
r.Next.Why, r.Next.WhyText = "credits", shortBy(r.Next.Price-p.S.Balance)
}
r.Next.CanBuy = r.Next.Why == ""
if p.Moon.Rating != nil {
r.Rating = *p.Moon.Rating
} else {
+47 -9
View File
@@ -10,6 +10,7 @@ import (
"log"
"net"
"net/http"
"net/netip"
"os"
"regexp"
"strconv"
@@ -218,21 +219,35 @@ func (l *rateLimiter) allow(r *http.Request) bool {
}
// clientIP is the connection's address, or with TRUST_PROXY=1 the address the proxy saw
// (the last X-Forwarded-For entry; earlier ones are client-supplied).
// (the last X-Forwarded-For entry; earlier ones are client-supplied), normalised.
func clientIP(r *http.Request) string {
if os.Getenv("TRUST_PROXY") == "1" {
if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
parts := strings.Split(xff[len(xff)-1], ",")
if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
return ip
return normalIP(ip)
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
return normalIP(r.RemoteAddr)
}
// normalIP is an address (with or without a port) as one key per client: IPv4-mapped IPv6
// as its IPv4, no zone, and every loopback address (127.0.0.0/8, ::1) as "loopback".
// Anything unparsable is kept as it is.
func normalIP(s string) string {
if host, _, err := net.SplitHostPort(s); err == nil {
s = host
}
ip, err := netip.ParseAddr(s)
if err != nil {
return r.RemoteAddr
return s
}
return host
ip = ip.Unmap().WithZone("")
if ip.IsLoopback() {
return "loopback"
}
return ip.String()
}
func serverError(w http.ResponseWriter, err error) {
@@ -318,12 +333,12 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
serverError(w, err)
return
}
state, err := loadState(r.Context(), s.db, userID)
state, v, err := loadView(r.Context(), s.db, userID, viewFromForm(r.FormValue))
if err != nil {
serverError(w, err)
return
}
page := state.page(viewFromForm(r.FormValue).resolve(state))
page := state.page(v)
if err := s.loadRanking(r.Context(), page); err != nil {
serverError(w, err)
return
@@ -527,6 +542,29 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
if err = hireAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " hired"
}
case "seat-advisor":
// Puts a hired advisor (advisor) in an empty seat (seat, from 1).
name := get("advisor")
seat, _ := strconv.Atoi(get("seat"))
if err = seatAdvisor(ctx, s.db, userID, name, seat); err == nil {
message = fmt.Sprintf("%s takes seat %d", name, seat)
}
case "unseat-advisor":
name := get("advisor")
if err = unseatAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " left their seat"
}
case "swap-advisors":
// Exchanges the seats of two hired advisors (advisor, with).
name, other := get("advisor"), get("with")
if err = swapAdvisors(ctx, s.db, userID, name, other); err == nil {
message = fmt.Sprintf("%s and %s swapped seats", name, other)
}
case "dismiss-advisor":
name := get("advisor")
if err = dismissAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " dismissed"
}
case "buy-seat":
var n int
if n, err = buyAdvisorSeat(ctx, s.db, userID); err == nil {
@@ -582,13 +620,13 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
return
}
state, err := loadState(ctx, s.db, userID)
state, v, err := loadView(ctx, s.db, userID, res.View)
if err != nil {
serverError(w, err)
return
}
toast := res.toast(state)
page := state.page(res.View.resolve(state))
page := state.page(v)
if err := s.loadRanking(ctx, page); err != nil {
serverError(w, err)
return
+18
View File
@@ -0,0 +1,18 @@
-- Advisors sit in numbered seats (advisors.go); seat null is hired but not seated. Hires
-- from before take seats 1, 2, ... in hiring order, once: only when the column is added,
-- so advisors unseated since stay unseated when this runs again.
do $$
begin
if not exists (select 1 from information_schema.columns
where table_name = 'advisor_hire' and column_name = 'seat') then
alter table advisor_hire add column seat smallint;
update advisor_hire set seat = numbered.n
from (select user_id, advisor, row_number() over (partition by user_id order by hired_at, advisor) as n
from advisor_hire) numbered
where advisor_hire.user_id = numbered.user_id and advisor_hire.advisor = numbered.advisor;
end if;
end
$$;
create unique index if not exists advisor_seat on advisor_hire (user_id, seat) where seat is not null;
+4 -1
View File
@@ -237,10 +237,13 @@ create index attachment_user on attachment (user_id);
create unique index attachment_once on attachment (inventory_item_id, item)
where inventory_item_id is not null;
-- Advisors hired (advisors.go), by config name.
-- Advisors hired (advisors.go), by config name. Seated ones (seat 1..users.advisor_seats)
-- give their bonus; seat null: hired but not seated.
create table advisor_hire (
user_id uuid not null references users(id) on delete cascade,
advisor text not null,
hired_at timestamp with time zone not null default now(),
seat smallint,
primary key (user_id, advisor)
);
create unique index advisor_seat on advisor_hire (user_id, seat) where seat is not null;
+48 -3
View File
@@ -32,8 +32,9 @@ func TestTemplates(t *testing.T) {
unlocked := newPlayerState() // everything open but no moon yet
unlocked.Onboarding = []string{"all"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} {
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
v = v.resolve(s)
p := s.page(v)
if v.Section == "leaderboard" {
@@ -53,6 +54,7 @@ func TestTemplates(t *testing.T) {
render("regions", p)
}
// The buy modal with no moons or only empty wells.
s.pagePastInMemory(1)
p := s.page(View{Section: "store"}.resolve(s))
p.OOB = true
p.Modal = &BuyModal{Item: &s.Config.StoreItems[0], NoMoons: true, AllEmpty: true}
@@ -166,6 +168,18 @@ func testViews(s *State) []View {
for _, r := range s.Refined {
views = append(views, View{Section: "market", Pick: r.Item})
}
for _, h := range s.Hires {
views = append(views, View{Section: "advisors", Adv: h.Name})
}
// The Past tab, its pages (one past the last: clamped) and a past moon from another page.
for page := range 4 {
views = append(views, View{Section: "moons", Past: true, Page: page})
}
for _, m := range s.Moons {
if m.Finished {
views = append(views, View{Section: "moons", Past: true, Page: 2, Moon: m.ID})
}
}
return views
}
@@ -265,9 +279,40 @@ func veteranState() *State {
s.Fitted[s.Tools[0].ID] = []string{c.Attachments.Items[0].Name}
s.Spare[c.Attachments.Items[1].Name] = 2
}
if len(c.Advisors.Items) > 0 {
// Advisors: one seated, one hired but not seated, a free seat.
if len(c.Advisors.Items) > 1 {
s.Hires = []AdvisorHire{{Name: c.Advisors.Items[0].Name, Seat: 1}, {Name: c.Advisors.Items[1].Name}}
s.Advisors = []string{c.Advisors.Items[0].Name}
s.AdvisorSeats = 1
s.AdvisorSeats = 2
}
// More past moons than fit a page (pastPerPage).
for n := range pastPerPage + 3 {
s.Moons = append(s.Moons, Moon{ID: testID("p", n), Name: fmt.Sprintf("Past %d", n), Kind: "Old",
Wells: []Well{{ID: testID("y", n), Resource: wells[0].Resource}}, CreatedAt: now.Add(-72 * time.Hour),
Finished: true, FinishedAt: now.Add(-time.Duration(n+2) * time.Hour),
Mined: []ResourceBalance{{Resource: wells[0].Resource, Balance: float64(n)}}})
}
s.pagePastInMemory(1)
return s
}
// fullBoardState is a veteran whose seats are all taken, with advisors waiting for one
// (and one no longer in the config).
func fullBoardState() *State {
c := cfg()
s := veteranState()
s.UserID = testID("u", 3)
s.Hires, s.Advisors = nil, nil
s.AdvisorSeats = 1 + len(c.Advisors.SeatPrices)
for i, a := range c.Advisors.Items {
seat := i + 1
if seat > s.AdvisorSeats {
seat = 0
} else {
s.Advisors = append(s.Advisors, a.Name)
}
s.Hires = append(s.Hires, AdvisorHire{Name: a.Name, Seat: seat})
}
s.Hires = append(s.Hires, AdvisorHire{Name: "Retired Advisor"})
return s
}
+58 -2
View File
@@ -17,10 +17,17 @@ userId=:user_id
# A view: s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon, moon=<id>, well=<id>,
# drill=<tool id> (equipment: the tool's attachment panel),
# pick=<resource or refined item> (market: the selected quote), list=past (moons: the Past tab,
# the only way a mined-out moon shows)
# the only way a mined-out moon shows), page=<n> (with list=past: 10 past moons a page, 1 the
# most recent), adv=<advisor name> (advisors: a hired advisor's panel)
GET :host/?s=store
:headers
GET :host/?list=past&page=2
:headers
GET :host/?s=advisors&adv=Ada+Voss
:headers
# Buy a moon
POST :host/act/buy-moon
:headers
@@ -118,6 +125,26 @@ advisor=Mira+Castellanos
POST :host/act/buy-seat
:headers
# Put a hired advisor in an empty seat (seat from 1; moves them if seated elsewhere)
POST :host/act/seat-advisor
:headers
advisor=Mira+Castellanos&seat=2
# Take a seated advisor off their seat (stays hired, no bonus)
POST :host/act/unseat-advisor
:headers
advisor=Mira+Castellanos
# Swap two hired advisors' seats (one may be unseated)
POST :host/act/swap-advisors
:headers
advisor=Mira+Castellanos&with=Ada+Voss
# Dismiss a hired advisor (frees their seat, nothing refunded)
POST :host/act/dismiss-advisor
:headers
advisor=Mira+Castellanos
# Start over as a new player (sets a new cookie)
POST :host/act/new-game
:headers
@@ -177,7 +204,8 @@ Content-Type: application/json
# Other sections' actions (clientSeq is echoed back): buy-tool (id), upgrade (tool),
# sell-tool (tool), buy-attachment (item), equip/unequip (item, to), sell-attachment (item),
# refine (recipe, qty or all), collect (job or none), buy-slot, hire (advisor), buy-seat,
# sell-moon (sell-moon)
# seat-advisor (advisor, seat), unseat-advisor (advisor), swap-advisors (advisor, with),
# dismiss-advisor (advisor), sell-moon (sell-moon)
POST :host/api/act/buy-tool
X-MM-User: :user_id
Content-Type: application/json
@@ -202,6 +230,34 @@ Content-Type: application/json
{"advisor": "Ada Voss", "s": "advisors"}
POST :host/api/act/seat-advisor
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "seat": 2, "s": "advisors"}
POST :host/api/act/unseat-advisor
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "s": "advisors"}
POST :host/api/act/swap-advisors
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "with": "Mira Castellanos", "s": "advisors"}
POST :host/api/act/dismiss-advisor
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "s": "advisors"}
# Past moons, a page at a time (state.pastPage)
GET :host/api/state?list=past&page=2
X-MM-User: :user_id
POST :host/api/act/sell-moon
X-MM-User: :user_id
Content-Type: application/json
+84 -15
View File
@@ -138,21 +138,24 @@ func formatCycle(secs int) string {
// ------------------------------------------------------------------ view
// View is what the player is looking at, carried in the URL:
// /?s=<section>&moon=<id>&well=<id>&list=past.
// /?s=<section>&moon=<id>&well=<id>&list=past&page=<n>.
type View struct {
Section string // moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon
Moon string
Past bool // the player opened the moon list's Past tab: a past moon may be on screen
Page int // the page of past moons (1: the most recent), from page=
Well string
Pick string // the selected resource or refined item (market section)
Drill string // the selected tool (equipment section)
Adv string // the selected hired advisor (advisors section), from adv=
}
var sections = map[string]bool{"moons": true, "equipment": true, "market": true, "store": true, "help": true, "refinery": true, "advisors": true, "leaderboard": true, "new-moon": true}
func viewFromForm(get func(string) string) View {
page, _ := strconv.Atoi(get("page"))
return View{Section: get("s"), Moon: get("moon"), Well: get("well"), Pick: get("pick"),
Drill: get("drill"), Past: get("list") == "past"}
Drill: get("drill"), Past: get("list") == "past", Page: page, Adv: get("adv")}
}
// resolve fills in defaults and drops ids that don't belong to the player.
@@ -170,13 +173,9 @@ func (v View) resolve(s *State) View {
if moon != nil && moon.Finished != v.Past {
moon = nil
}
if moon == nil && v.Past {
// The Past tab: the last moon finished.
for i := range s.Moons {
if s.Moons[i].Finished && (moon == nil || s.Moons[i].FinishedAt.After(moon.FinishedAt)) {
moon = &s.Moons[i]
}
}
if moon == nil && v.Past && len(s.PastIDs) > 0 {
// The Past tab: the last moon finished (on its page).
moon = s.moon(s.PastIDs[0])
}
if moon == nil {
v.Past = false
@@ -201,6 +200,10 @@ func (v View) resolve(s *State) View {
v.Drill = ""
}
v.Drill = s.idleCopy(v.Drill)
if v.Section != "advisors" || s.hire(v.Adv) == nil {
v.Adv = ""
}
v.Page = s.PastPage
return v
}
@@ -214,6 +217,9 @@ func (v View) URL() string {
}
if v.Past && v.Section == "moons" {
q.Set("list", "past")
if v.Page > 1 {
q.Set("page", strconv.Itoa(v.Page))
}
}
if v.Well != "" {
q.Set("well", v.Well)
@@ -224,6 +230,9 @@ func (v View) URL() string {
if v.Drill != "" {
q.Set("drill", v.Drill)
}
if v.Adv != "" {
q.Set("adv", v.Adv)
}
if len(q) == 0 {
return "/"
}
@@ -239,15 +248,20 @@ func (v View) ToMoon(id string) string {
return View{Section: "moons", Moon: id}.URL()
}
// ToPastMoon opens a finished moon from the Past tab.
// ToPastMoon opens a finished moon from the Past tab (on the current page of it).
func (v View) ToPastMoon(id string) string {
return View{Section: "moons", Moon: id, Past: true}.URL()
return View{Section: "moons", Moon: id, Past: true, Page: v.Page}.URL()
}
func (v View) ToWell(id string) string {
return View{Section: "moons", Moon: v.Moon, Well: id}.URL()
}
// ToAdvisor opens a hired advisor's panel in the advisors section.
func (v View) ToAdvisor(name string) string {
return View{Section: "advisors", Moon: v.Moon, Adv: name}.URL()
}
// ToDrill opens a tool's panel in the equipment section.
func (v View) ToDrill(id string) string {
return View{Section: "equipment", Moon: v.Moon, Drill: id}.URL()
@@ -351,10 +365,28 @@ func (p *Page) ActiveURL() string {
}
func (p *Page) PastURL() string {
if len(p.Past) == 0 {
if p.S.PastTotal == 0 {
return ""
}
return p.V.ToPastMoon(p.Past[0].ID)
return View{Section: "moons", Past: true}.URL()
}
// PastPaging is the Past tab's paging: the page shown, how many there are, and links to
// the newer and older pages ("" at either end).
type PastPaging struct {
Page, Pages, Total int
Newer, Older string
}
func (p *Page) PastPaging() PastPaging {
pg := PastPaging{Page: p.S.PastPage, Pages: p.S.pastPages(), Total: p.S.PastTotal}
if pg.Page > 1 {
pg.Newer = View{Section: "moons", Past: true, Page: pg.Page - 1}.URL()
}
if pg.Page < pg.Pages {
pg.Older = View{Section: "moons", Past: true, Page: pg.Page + 1}.URL()
}
return pg
}
type MoonDetail struct {
@@ -742,7 +774,9 @@ func (s *State) page(v View) *Page {
row.Pending = endsAttr(pending)
p.Ready += row.Ready
if m.Finished {
if slices.Contains(s.PastIDs, m.ID) {
p.Past = append(p.Past, row)
}
} else {
p.Moons = append(p.Moons, row)
}
@@ -752,7 +786,10 @@ func (s *State) page(v View) *Page {
}
p.Pending = endsAttr(allPending)
p.PastView = v.Past
sort.SliceStable(p.Past, func(i, j int) bool { return p.Past[i].FinishedAt.After(p.Past[j].FinishedAt) })
// In the page's order (most recently finished first).
sort.SliceStable(p.Past, func(i, j int) bool {
return slices.Index(s.PastIDs, p.Past[i].ID) < slices.Index(s.PastIDs, p.Past[j].ID)
})
switch v.Section {
case "moons":
@@ -897,7 +934,14 @@ func (s *State) page(v View) *Page {
p.Refinery = s.refineryPage()
case "advisors":
p.Advisors = s.advisorsPage()
p.Advisors = s.advisorsPage(v)
// The advisor panel goes in the side panel, under the current moon.
if p.Advisors.Panel != nil {
p.Scene.Select = map[string]string{}
if v.Moon != "" {
p.Scene.Select["moonId"] = v.Moon
}
}
}
return p
}
@@ -1350,6 +1394,31 @@ func (r WellRow) Full() bool {
return r.Supply > 0 && (r.Demand > 0 && r.Demand >= r.Supply-1e-9 || r.Remain <= 1e-9 && r.Taken() > 0)
}
// StatusText is the well note's lead (template "well-note"): "Over-drilled — next claims
// will come up short", "Fully committed" or "".
func (r WellRow) StatusText() string {
switch {
case r.Over():
return "Over-drilled — next claims will come up short"
case r.Full():
return "Fully committed"
}
return ""
}
// NoteText is the rest of the well note, as the template words it: "47 left · 2 committed ·
// 45 free after" or "… · runs dry after 3 claims"; "" when no drill wants anything from it.
func (r WellRow) NoteText() string {
if r.Demand == 0 {
return ""
}
t := formatMoney(r.Supply) + " left · " + formatMoney(r.Demand) + " committed"
if r.DryAfter > 0 {
return t + " · runs dry after " + plural(r.DryAfter, "claim")
}
return t + " · " + formatMoney(r.Remain) + " free after"
}
// BarStack is a wells table row's timer bars (template "drill-stack"): the first drills,
// oldest first, and how many it leaves out. Key makes the bars' ids unique on the page
// (the morph matches them per drill).
+82 -1
View File
@@ -1288,6 +1288,74 @@ a.htmx-request::after {
.advisor-card .card-buy.ready {
text-align: center;
}
/* Advisor seats (who sits where), the hired advisors without a seat, and the advisor
panel's portrait. */
.adv-seats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
gap: 12px;
margin: 16px 0;
padding: 0;
list-style: none;
}
.adv-seat-in {
display: flex;
align-items: center;
gap: 12px;
min-height: 76px;
padding: 10px 12px;
border: 1px solid rgb(var(--ready-rgb) / 0.4);
background: var(--panel);
color: inherit;
text-decoration: none;
transition: border-color 0.15s;
}
.adv-seat.is-empty .adv-seat-in {
border: 1px dashed var(--edge);
background: transparent;
}
a.adv-seat-in:hover,
a.adv-seat-in[aria-current] {
border-color: rgb(var(--ready-rgb) / 0.9);
}
.adv-seat-in img {
width: 54px;
height: 54px;
object-fit: contain;
background: var(--bg);
}
.adv-bench-title {
margin-top: 8px;
}
.adv-bench {
margin-bottom: 16px;
}
.adv-bench a {
color: inherit;
text-decoration: none;
}
.adv-portrait {
display: flex;
justify-content: center;
height: 140px;
margin: 10px 0;
background: var(--bg);
}
.adv-portrait img {
height: 100%;
aspect-ratio: 1;
object-fit: contain;
}
.bonus-chips {
display: flex;
flex-wrap: wrap;
@@ -4570,6 +4638,19 @@ textarea.eco-input {
filter: none;
}
.past-paging {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
text-align: center;
}
.past-paging [aria-disabled] {
opacity: 0.4;
pointer-events: none;
}
.past-moon .total td {
color: var(--text);
font-weight: 600;
@@ -4916,7 +4997,7 @@ textarea.eco-input {
gap: 12px;
}
.reveal-foot .btn {
.reveal-foot .btn:first-child {
margin-left: auto;
}
+5 -1
View File
@@ -168,7 +168,7 @@ function play(kind, toast, prev, at) {
});
}
if (["buy-tool", "upgrade", "buy-moon", "buy-attachment", "equip", "hire", "buy-seat"].includes(kind)) {
if (["buy-tool", "upgrade", "buy-moon", "buy-attachment", "equip", "hire", "buy-seat", "seat-advisor", "swap-advisors"].includes(kind)) {
if (at) burst(at.x, at.y, [CYAN, CYAN_HI], 34);
// The page may change under a new moon, so sweep its list entry instead.
const moon = kind === "buy-moon" && document.querySelector("#moonlist .moon[aria-current]");
@@ -210,6 +210,10 @@ function play(kind, toast, prev, at) {
equip: "upgrade",
unequip: "toggle",
hire: "confirm",
"seat-advisor": "upgrade",
"unseat-advisor": "toggle",
"swap-advisors": "toggle",
"dismiss-advisor": "powerdown",
"buy-seat": "buy",
"buy-slot": "buy",
upgrade: "upgrade",
+2 -2
View File
@@ -120,9 +120,9 @@ window.addEventListener("popstate", () => {
// Actions keep the current view: send it along unless the button set its own.
document.body.addEventListener("htmx:configRequest", (event) => {
if (event.detail.verb !== "post") return;
const { s, moon, well, drill, list } = view();
const { s, moon, well, drill, list, page, adv } = view();
const params = event.detail.parameters;
for (const [key, value] of Object.entries({ s, moon, well, drill, list })) {
for (const [key, value] of Object.entries({ s, moon, well, drill, list, page, adv })) {
// params is a FormData proxy: `in` never sees its keys, a missing one reads undefined.
if (params[key] === undefined && value) params[key] = value;
}
+16 -1
View File
@@ -11,7 +11,8 @@
// balance down ("mm:revealed").
//
// A click or key skips ahead: during the charge to the burst, during the card to its end
// state; once the card is complete it closes it. With prefers-reduced-motion the moon
// state; once the card is complete it closes it. "Buy another" closes it and buys the next
// moon (buy-moon), whose response plays a new reveal. With prefers-reduced-motion the moon
// swaps in and the card fades.
import * as sfx from "./sfx.js";
@@ -77,6 +78,7 @@ export function playReveal({ scene, seed, settings, source }) {
let stage = "charge";
let card = null;
let cardDone = false;
let buyAnother = false;
// Narrow screens: the viewport sits below the main area; bring it into view (on phones
// at the top, the card comes up from the bottom).
@@ -325,6 +327,7 @@ export function playReveal({ scene, seed, settings, source }) {
);
}
document.dispatchEvent(new CustomEvent("mm:revealed"));
if (buyAnother) htmx.ajax("POST", "/act/buy-moon", { source: document.body, swap: "none" });
};
sfx.play("close");
layer.animate([{ opacity: 1 }, { opacity: 0 }], { duration: quick ? 150 : 420, easing: "ease-in", fill: "forwards" }).onfinish = done;
@@ -362,9 +365,19 @@ export function playReveal({ scene, seed, settings, source }) {
else close();
}
// "Buy another": once this reveal is gone (the next one replaces the card).
function buy(button) {
if (!cardDone || button.disabled) return false;
buyAnother = true;
close();
return true;
}
function onPointer(event) {
event.preventDefault();
if (event.target.closest?.("[data-reveal-close]") && cardDone) return close();
const more = event.target.closest?.("[data-reveal-buy]");
if (more && buy(more)) return;
advance();
}
function onKey(event) {
@@ -372,6 +385,8 @@ export function playReveal({ scene, seed, settings, source }) {
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape" && stage === "card") return close();
const more = document.activeElement?.closest?.("[data-reveal-buy]");
if (event.key !== "Escape" && more && buy(more)) return;
advance();
}
layer.addEventListener("pointerdown", onPointer);
+94 -6
View File
@@ -1,11 +1,13 @@
{{/* The advisors section (advisors.go): the combined bonus, the seats, and every advisor
with a Hire button. */}}
{{/* The advisors section (advisors.go): the combined bonus, the seats and who sits in them,
the hired advisors without a seat, and every advisor with a Hire button. A hired
advisor opens their panel (advisor-panel, in the side panel) to move, swap, unseat or
dismiss them. */}}
{{define "advisors-section"}}{{with .Advisors}}
{{define "advisors-section"}}{{$p := .}}{{with .Advisors}}
<div class="heading">
<div class="grow">
<h1>Advisors</h1>
<p class="muted measure">Each advisor takes one seat and stays for good.</p>
<p class="muted measure">Each seated advisor boosts your whole operation. Click one to move, swap or dismiss them.</p>
</div>
{{if .NextSeatPrice}}
<button class="btn btn-dashed" hx-post="/act/buy-seat"
@@ -20,6 +22,36 @@
<div class="mk-stat frame"><dt class="cap">Active bonuses</dt><dd>{{with .Bonuses}}{{template "bonus-chips" .}}{{else}}<span class="muted">None yet</span>{{end}}</dd></div>
</dl>
<ol class="adv-seats" aria-label="Advisor seats">
{{range .SeatList}}{{$n := .N}}
<li id="seat-{{.N}}" class="adv-seat{{if not .Advisor}} is-empty{{end}}">
{{with .Advisor}}
{{$u := $p.V.ToAdvisor .Name}}
<a href="{{$u}}" hx-get="{{$u}}" class="adv-seat-in" {{if eq $p.V.Adv .Name}}aria-current="true"{{end}}>
<img src="/static/assets/advisors/{{.Image}}" alt="" onerror="this.style.visibility='hidden'">
<span class="grow"><span class="cap num">Seat {{$n}}</span><br><b>{{.Name}}</b><br><span class="muted small num">{{.Effect}}</span></span>
</a>
{{else}}
<span class="adv-seat-in"><span class="grow"><span class="cap num">Seat {{.N}}</span><br><span class="muted">Empty</span></span></span>
{{end}}
</li>
{{end}}
</ol>
{{with .Bench}}
<h3 class="cap adv-bench-title">Hired, not seated <span class="muted">· no bonus</span></h3>
<div class="adv-bench">
{{range .}}
{{$u := $p.V.ToAdvisor .Name}}
<div class="line">
<a class="grow" href="{{$u}}" hx-get="{{$u}}" {{if eq $p.V.Adv .Name}}aria-current="true"{{end}}><b>{{.Name}}</b><br><span class="muted small num">{{.Effect}}</span></a>
<button class="btn btn-sm btn-primary" hx-post="/act/seat-advisor" hx-vals="{{vals "advisor" .Name "seat" (print $p.Advisors.FreeSeat)}}"
{{with $p.Advisors.SeatWhy}}disabled title="{{.}}"{{end}}>Seat</button>
</div>
{{end}}
</div>
{{end}}
<div class="cards">
{{range .Cards}}
<article class="card advisor-card {{if .Hired}}is-hired{{end}}">
@@ -30,13 +62,69 @@
<p class="mod-effect num">{{.Effect}}</p>
<p class="card-desc">{{.Description}}</p>
{{if .Hired}}
<p class="ready card-buy">On your board</p>
{{$u := $p.V.ToAdvisor .Name}}
<a class="btn card-buy" href="{{$u}}" hx-get="{{$u}}">{{if .Seat}}Seat {{.Seat}}{{else}}Not seated{{end}} · Manage</a>
{{else}}
<button class="btn btn-primary card-buy" hx-post="/act/hire" hx-vals="{{vals "advisor" .Name}}"
{{with $.Advisors.HireWhy . $.S.Balance}}disabled title="{{.}}"{{end}}>Hire · <span class="num">{{cur .Price}}</span></button>
{{with $p.Advisors.HireWhy . $p.S.Balance}}disabled title="{{.}}"{{end}}>Hire · <span class="num">{{cur .Price}}</span></button>
{{end}}
</div>
</article>
{{end}}
</div>
{{end}}{{end}}
{{/* A hired advisor's panel (View.Adv), in the side panel. Called with the Page. */}}
{{define "advisor-panel"}}{{$p := .}}{{with .Advisors.Panel}}
<h2>{{.Name}}</h2>
<p class="cap">{{.Title}}</p>
<div class="adv-portrait"><img src="/static/assets/advisors/{{.Image}}" alt="" onerror="this.style.visibility='hidden'"></div>
{{with .Effect}}<p class="mod-effect num">{{.}}</p>{{end}}
<p class="muted small">{{.Description}}</p>
<dl class="facts">
<div><dt>Seat</dt><dd class="num">{{if .Seat}}{{.Seat}}{{else}}Not seated{{end}}</dd></div>
<div><dt>Bonus</dt><dd>{{if .Seat}}Active{{else}}<span class="muted">Off until seated</span>{{end}}</dd></div>
</dl>
<p class="muted small">Changes count from your next drill, sale or batch.</p>
{{$name := .Name}}
{{with .Moves}}
<h3>{{if $p.Advisors.Panel.Seat}}Move to{{else}}Seat in{{end}}</h3>
{{range .}}
<div class="line">
<span class="cap num">{{.N}}</span>
{{with .Advisor}}
<span class="grow">{{.Name}}<br><span class="muted small num">{{.Effect}}</span></span>
<button class="btn btn-sm" hx-post="/act/swap-advisors" hx-vals="{{vals "advisor" $name "with" .Name}}">{{if $p.Advisors.Panel.Seat}}Swap{{else}}Replace{{end}}</button>
{{else}}
<span class="grow muted">Empty</span>
<button class="btn btn-sm btn-primary" hx-post="/act/seat-advisor" hx-vals="{{vals "advisor" $name "seat" (print .N)}}">{{if $p.Advisors.Panel.Seat}}Move{{else}}Seat{{end}}</button>
{{end}}
</div>
{{end}}
{{end}}
{{if .Seat}}
{{with .Swaps}}
<h3>Give the seat to</h3>
{{range .}}
<div class="line">
<span class="grow">{{.Name}}<br><span class="muted small num">{{.Effect}}</span></span>
<button class="btn btn-sm" hx-post="/act/swap-advisors" hx-vals="{{vals "advisor" $name "with" .Name}}">Swap in</button>
</div>
{{end}}
{{end}}
<h3>Unseat</h3>
<div class="line">
<span class="grow muted small">Frees seat {{.Seat}}. {{.Name}} stays hired, without a bonus, until seated again.</span>
<button class="btn btn-sm" hx-post="/act/unseat-advisor" hx-vals="{{vals "advisor" .Name}}">Unseat</button>
</div>
{{end}}
<h3>Dismiss</h3>
<div class="line">
<span class="grow muted small">Lets {{.Name}} go{{if .Seat}} and frees seat {{.Seat}}{{end}}. Nothing is refunded.</span>
<button class="btn btn-sm btn-danger" hx-post="/act/dismiss-advisor" hx-vals="{{vals "advisor" .Name}}"
hx-confirm="{{.Dismiss}}" data-confirm-title="Dismiss advisor" data-confirm-ok="Dismiss" data-confirm-danger>Dismiss</button>
</div>
{{end}}{{end}}
+13 -2
View File
@@ -28,7 +28,7 @@
data-now (the server's clock) lines the countdowns up with the server. */}}
{{define "view"}}
<div id="view" hidden {{template "oob-replace" .}} data-url="{{.V.URL}}" data-s="{{.V.Section}}"
data-moon="{{.V.Moon}}" data-list="{{if .V.Past}}past{{end}}" data-well="{{.V.Well}}" data-drill="{{.V.Drill}}" data-now="{{.S.Now.UnixMilli}}"></div>
data-moon="{{.V.Moon}}" data-list="{{if .V.Past}}past{{end}}" data-well="{{.V.Well}}" data-drill="{{.V.Drill}}" data-adv="{{.V.Adv}}" data-page="{{if and .V.Past (gt .V.Page 1)}}{{.V.Page}}{{end}}" data-now="{{.S.Now.UnixMilli}}"></div>
{{end}}
{{define "scene"}}
@@ -153,7 +153,7 @@
{{$active := .ActiveURL}}
<a href="{{$active}}" hx-get="{{$active}}" {{if not .PastView}}aria-current="page"{{end}}>Active <span class="muted num">{{len .Moons}}</span></a>
{{with .PastURL}}
<a href="{{.}}" hx-get="{{.}}" {{if $.PastView}}aria-current="page"{{end}} data-past-tab>Past <span class="muted num">{{len $.Past}}</span></a>
<a href="{{.}}" hx-get="{{.}}" {{if $.PastView}}aria-current="page"{{end}} data-past-tab>Past <span class="muted num">{{$.S.PastTotal}}</span></a>
{{else}}
<a aria-disabled="true" title="Moons you mine out go here" data-past-tab>Past <span class="muted num">0</span></a>
{{end}}
@@ -172,6 +172,13 @@
<span class="grow"><b>{{.Name}}</b>{{template "rating-chip" .Rating}}<br><span class="muted small num">Mined {{money .TotalMined}}{{with .FinishedOn}} · {{.}}{{end}}</span></span>
</a>
{{end}}
{{with .PastPaging}}{{if gt .Pages 1}}
<nav class="past-paging small" aria-label="Past moon pages">
{{with .Newer}}<a class="btn btn-sm" href="{{.}}" hx-get="{{.}}" rel="prev">‹ Newer</a>{{else}}<span class="btn btn-sm" aria-disabled="true">‹ Newer</span>{{end}}
<span class="muted num grow">{{.Page}} / {{.Pages}}</span>
{{with .Older}}<a class="btn btn-sm" href="{{.}}" hx-get="{{.}}" rel="next">Older ›</a>{{else}}<span class="btn btn-sm" aria-disabled="true">Older ›</span>{{end}}
</nav>
{{end}}{{end}}
{{else}}
{{range .Moons}}
{{$url := $.V.ToMoon .ID}}
@@ -585,6 +592,9 @@
{{end}}
</ul>
<div class="reveal-foot">
{{with .Next}}{{if ne .Why "locked"}}
<button type="button" class="btn" data-reveal-buy {{if not .CanBuy}}disabled title="{{.WhyText}}"{{end}}>Buy another · <span class="num">{{cur .Price}}</span></button>
{{end}}{{end}}
<button type="button" class="btn btn-primary" data-reveal-close>Start mining</button>
</div>
</section>
@@ -640,6 +650,7 @@
{{define "side"}}
<section id="side" class="side{{with .Well}}{{if le .Supply 0.0}} depleted{{end}}{{end}}" {{template "oob" .}}>
{{with .Drill}}{{template "drill-panel" .}}{{end}}
{{with .Advisors}}{{with .Panel}}{{template "advisor-panel" $}}{{end}}{{end}}
{{with .Well}}
{{$dry := le .Supply 0.0}}
<h2>{{template "dot" .Resource}}{{.Resource}} well</h2>