Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e267992615 | ||
|
|
fa5fc46cb8 | ||
|
|
7af1c5a821 | ||
|
|
fcec292660 |
No files matched your search
@@ -5,4 +5,5 @@ node_modules
|
|||||||
.env*
|
.env*
|
||||||
.vscode
|
.vscode
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
web/static/css/app.css
|
||||||
art
|
art
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Build output
|
||||||
|
/bin/
|
||||||
|
/moonminers
|
||||||
|
/web/static/css/app.css
|
||||||
|
|
||||||
# Local env files (never commit)
|
# Local env files (never commit)
|
||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
@@ -7,12 +12,14 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Personal files
|
# Personal files
|
||||||
|
/sql/create-users.sql
|
||||||
/.dir-locals.el
|
/.dir-locals.el
|
||||||
|
sql/queries.sql
|
||||||
/project.todo
|
/project.todo
|
||||||
|
|
||||||
# Blender files
|
# Blender files (generated by art/equipment scripts)
|
||||||
*.blend
|
*.blend
|
||||||
*.blend1
|
*.blend1
|
||||||
|
/art/equipment/build/
|
||||||
# Agent worktree state (scripts/wt)
|
/art/advisors/build/
|
||||||
/.wt/
|
/art/attachments/build/
|
||||||
@@ -1,52 +1,193 @@
|
|||||||
# AGENTS.md — Moon Miners
|
# AGENTS.md — Moon Miners
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
Moon Miners is an idle/mining game. Players join anonymously: the server creates a
|
Moon Miners is a browser idle/mining game. Players join anonymously: the server creates a
|
||||||
player and keeps its id in the `mm_user` cookie (or `X-MM-User` for the API). Players own
|
player and keeps its id in the `mm_user` cookie. Players own moons ("staking sources") with
|
||||||
moons ("staking sources") with resource wells, buy and upgrade mining tools (store items)
|
resource wells, buy and upgrade mining tools (store items) and stake them on wells. When a
|
||||||
and stake them on wells. When a stake finishes (drill cycles are seconds:
|
stake finishes (drill cycles are seconds: `completionTimeInSecs`, `duration_in_secs`) they
|
||||||
`completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
|
claim resources (Sollux, Shadowstone, Azurium, Novafor, Nebulance), which
|
||||||
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined
|
they can sell for credits (₥) or refine into refined items (Refinery section: timed batches
|
||||||
items (Refinery section: timed batches in slots; refined items sell for more and are parts
|
in slots; refined items sell for more and are parts for higher tool upgrades). Drill
|
||||||
for the higher upgrades). Each drill has two upgrade tracks of its own (Equipment: one raises
|
attachments (bought in the Store, equipped on a drill from its panel in Equipment) give it
|
||||||
its claim per cycle, the other shortens its cycle, a level at a time, per tool); advisors
|
a modifier that makes it faster or bigger; advisors (Advisors section, one per seat) boost drill speed, yield, sell prices or the
|
||||||
(Advisors section, one per seat) boost drill speed, yield, sell prices or the refinery for
|
refinery for the whole account. There is also a leaderboard (Leaderboard section, `leaderboard.go`: top miners by credits, banned players left out; also JSON at `/api/leaderboard`).
|
||||||
the whole account. There is also a leaderboard (Leaderboard section: top miners by credits,
|
|
||||||
banned players left out).
|
|
||||||
|
|
||||||
## How the parts fit
|
## Stack
|
||||||
- `server/`: the Go server: game rules, Postgres, the JSON API and the admin pages
|
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
|
||||||
(`/utils`, server-rendered with htmx, behind `ADMIN_PASSWORD`). The server is the only
|
package at the repo root; templates, static files and SQL are embedded with `go:embed`.
|
||||||
authority on game state. Details: `server/AGENTS.md`.
|
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
|
||||||
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's
|
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
|
||||||
JSON API (`/api/state`, `/api/act/<action>`, documented in `server/api.md`) and shows
|
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
|
||||||
what it sends. It is the only game client, and its moon generator
|
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
|
||||||
(`client/modules/moon/terrain.lua`, native `mmterrain`) is the only one. Details:
|
The viewport in the aside shows the selected moon, generated procedurally from its id
|
||||||
`client/AGENTS.md`.
|
(`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
|
||||||
- Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
|
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
|
||||||
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
|
- Postgres 16, stock image. No functions or extensions in the database.
|
||||||
|
|
||||||
## Workspace
|
## Layout
|
||||||
- The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace,
|
- The repo is `server/` in the `MoonMiners/` workspace; agent worktrees go next to it
|
||||||
outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`).
|
(`git worktree add ../wt-<task> -b <task>`), a Defold client later in `../client`.
|
||||||
- Agent worktrees go in the workspace, next to `game/`. Make them with `scripts/wt` (from
|
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
|
||||||
the workspace: `game/scripts/wt up <task>`), not by hand: it makes `../wt-<task>` on
|
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
|
||||||
branch `<task>`, copies the Defold build cache (no remote engine build) and `.env`,
|
- `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it
|
||||||
creates and migrates the database `mm_<task>`, and starts a server (MM_DEV=1) on a free
|
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
|
||||||
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
|
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
|
||||||
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
|
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
|
||||||
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
|
owned for the moon price and well scale, and can't be sold.
|
||||||
- A worktree's `.env` names its own database (`DB_DATABASE=mm_<task>`), so `migrate`,
|
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
|
||||||
tests and servers run there reach only `mm_<task>`. Never point anything at the main
|
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
|
||||||
database (`moonminers`): it holds the author's game.
|
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
|
||||||
- Never use `git stash` in a worktree: the stash list is shared by every worktree, so a
|
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
|
||||||
pop can take another agent's work. Commit on your branch, or save a patch file.
|
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
|
||||||
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
action and responds the same way.
|
||||||
commit it.
|
- `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.
|
||||||
|
`rollWells` (game.go) rolls a new moon's wells: 2–5 by weight, resources by its type's
|
||||||
|
affinities, each well's supply rolled in [`wellSupplyMin`, `wellSupplyMax`) (fewer wells
|
||||||
|
hold less); a first moon
|
||||||
|
always has every raw input of a recipe. A moon's name comes from `moonNames` (roots and
|
||||||
|
suffix style weights, `rollMoonName`), never repeated among a player's moons; its type is
|
||||||
|
`staking_source.kind` (moons from before migration 013 were named after their type).
|
||||||
|
`config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item`
|
||||||
|
(needed for the inventory foreign key and the SQL in game.go). Admins override its
|
||||||
|
values live on `/utils/economy` (table editor in `economy.js`; `economy.go`: a patch per
|
||||||
|
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim). Read config values through
|
||||||
|
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
|
||||||
|
- `db.go`: connections, `inTx`, `GameError`.
|
||||||
|
- `market.go`: market prices. A unit sells for its config price × a deterministic drift
|
||||||
|
(name + 5-minute tick, within ±`marketVolatility`) × live price events × the player's
|
||||||
|
selling pressure (`marketPressure`: their recent sales of the item, in base-price credits,
|
||||||
|
decaying with a half-life, table `market_pressure`; the factor eases from 1 towards
|
||||||
|
`floor`, and a sale is priced at its average over the pressure it adds). `marketRate` is
|
||||||
|
used by `sell`/`sellRefined` and the market view (24h history recomputed, no table);
|
||||||
|
view model for the Market section (ticker, watchlist, chart, sell ticket; `pick=` in the
|
||||||
|
URL selects a quote). `web/static/js/market.js`: chart crosshair and ticket estimate.
|
||||||
|
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
|
||||||
|
market price (with live events, without selling pressure or advisors) × `factor`, capped
|
||||||
|
at `maxRatio` × the replacement moon's price (`moonPriceFor(owned-1)`), which it's
|
||||||
|
compared with. Not the last moon, not within `minHoldSecs` of buying it, not while a
|
||||||
|
drill on it is running; finished drills are claimed in the sale. Template: `moon-sale`.
|
||||||
|
Also selling tools and spare attachments for `equipmentResale.factor` (below 1) of their
|
||||||
|
credit price (a tool: price + its upgrades; refined parts not refunded): only idle
|
||||||
|
tools with nothing to claim, never the last tool; attachments on it return to the inventory.
|
||||||
|
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on upgrade tiers), rules
|
||||||
|
(start batch, collect, buy slot, sell refined, upgrade parts) and its view model. A batch
|
||||||
|
of n takes `seconds` × n^`bulkExponent` (`batchSecs`). Tables
|
||||||
|
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
|
||||||
|
- `bonus.go`: player bonuses. `bonusesFor` combines advisors and a tool's attachments
|
||||||
|
(multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
|
||||||
|
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (used by
|
||||||
|
`stakeInTx`, the views and the sim); sells and refinery batches use `Bonus.Sell`/`Refine`.
|
||||||
|
Bonuses are fixed when the action runs (a stake keeps its duration and amount).
|
||||||
|
- `attachments.go`: attachments config (`attachments` in game.json: `slotsPerTool`, items
|
||||||
|
with `speed`/`yield` multipliers), buy/equip/unequip rules (one of each per tool; equip
|
||||||
|
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`.
|
||||||
|
- `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
|
||||||
|
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 `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`
|
||||||
|
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged as `admin_*` in
|
||||||
|
`event_log`). `notBanned` gives banned players (`users.banned_at`) a suspended page.
|
||||||
|
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put
|
||||||
|
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`.
|
||||||
|
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon
|
||||||
|
list, sections, side panel).
|
||||||
|
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's
|
||||||
|
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`)
|
||||||
|
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene.
|
||||||
|
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view
|
||||||
|
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game
|
||||||
|
and admin pages.
|
||||||
|
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound
|
||||||
|
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored
|
||||||
|
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for
|
||||||
|
outside control (e.g. ads).
|
||||||
|
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and
|
||||||
|
`web/templates/moons.html`: the moon lab. `moons.go`: moon settings endpoints, `isAdmin`.
|
||||||
|
`moondesc.go`: moon descriptions generated from the look (mirrors terrain.js `moonTraits`;
|
||||||
|
`scripts/check-moon-traits.mjs` in `make check` keeps them in sync) and the wells.
|
||||||
|
- `reveal.go`, `web/static/js/reveal.js`: the new-moon section (`s=new-moon`, a teaser with a
|
||||||
|
shrouded moon in the viewport, `mystery` in the scene data) and the reveal after
|
||||||
|
`buy-moon` (the response carries `#reveal` with the moon's card; scene.js plays the
|
||||||
|
burst, fx.js waits for `mm:revealed` before counting the balance down).
|
||||||
|
`rating.go`: a new moon's rating, the percentile of its base-price value among a cached
|
||||||
|
sample of `rollWells` (resampled when the config changes), stored in
|
||||||
|
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
|
||||||
|
and the star chip in the moon list and header.
|
||||||
|
- `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.
|
||||||
|
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
|
||||||
|
|
||||||
## Conventions (all parts)
|
## Commands
|
||||||
- The server is the authority: clients show the state it sends and send actions; no game
|
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind watching.
|
||||||
rules on the client side beyond what `server/api.md` "Prediction" allows.
|
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`,
|
||||||
- Work in one part at a time: a client task doesn't change `server/`, and the other way
|
moon traits check), `make migrate`, `make load-config`. `go run . sim` prints the economy analysis.
|
||||||
round, unless the task says so.
|
- Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the
|
||||||
|
templates like the server and renders every page and region against in-memory game states;
|
||||||
|
when you add a page, section or view model branch, make sure its states/views reach it.
|
||||||
|
- Local DB: `podman-compose up -d db` then `make migrate` (see README).
|
||||||
|
There is no other test suite, except `wells_test.go` (`go test -run Wells -v`: well roll
|
||||||
|
distribution, value by well count, resale odds, sim seeds).
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
- Production uses `docker-compose.prod.yml` (deployed with Dokploy): `app`, `db` (stock
|
||||||
|
Postgres) and `migrate` (same image as `app`, runs on every deploy before the app starts).
|
||||||
|
- Schema changes need both an update to `sql/tables.sql` (fresh DBs) and a new idempotent
|
||||||
|
`sql/migrations/NNN-name.sql` (existing DBs). Check that a fresh DB and one built from the
|
||||||
|
pre-Go schema (`git show 610d26e:sql/tables.sql`) plus all migrations give the same
|
||||||
|
`pg_dump --schema-only`.
|
||||||
|
- Keep `docker-compose.prod.yml` services on the explicit `internal` network (Dokploy adds its own network).
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
Postgres credentials come from `.env`: `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
|
||||||
|
`DB_DATABASE`, plus `ADMIN_DB_USER`/`ADMIN_DB_PASSWORD` for `migrate`. Never read, print or
|
||||||
|
commit `.env`. `ADMIN_USER_IDS` (comma-separated player ids) grants access to `/utils`
|
||||||
|
(unset: nobody); with `MM_DEV=1` everyone is an admin.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
|
||||||
|
- Layout: header, moon list, main area (one section at a time), and an aside with the 3D
|
||||||
|
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.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
|
||||||
|
(`data-ends`) tick client-side and may enable a Claim button, as display only.
|
||||||
|
- SQL is lowercase with snake_case identifiers.
|
||||||
|
- When you add an endpoint, add it to `test-endpoints.restclient`.
|
||||||
|
- New player action: log it with `logEvent(ctx, tx, userID, "<kind>", data)` inside its
|
||||||
|
transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
|
||||||
|
- Dispose any three.js geometry and materials you create.
|
||||||
- Keep changes small. Don't refactor or move files unless asked.
|
- Keep changes small. Don't refactor or move files unless asked.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Production image: one Go binary with the templates, static files and SQL embedded.
|
||||||
|
FROM docker.io/library/golang:1.25-bookworm AS build
|
||||||
|
WORKDIR /src
|
||||||
|
ARG TAILWIND_VERSION=v4.1.14
|
||||||
|
RUN curl -fsSL -o /usr/local/bin/tailwindcss \
|
||||||
|
https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 \
|
||||||
|
&& chmod +x /usr/local/bin/tailwindcss
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \
|
||||||
|
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=build /moonminers /moonminers
|
||||||
|
USER 65532:65532
|
||||||
|
ENV PORT=3000
|
||||||
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT ["/moonminers"]
|
||||||
|
CMD ["serve"]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
TAILWIND_VERSION := v4.1.14
|
||||||
|
TAILWIND := bin/tailwindcss
|
||||||
|
CSS := web/static/css/app.css
|
||||||
|
|
||||||
|
.PHONY: build dev css migrate load-config check
|
||||||
|
|
||||||
|
build: css
|
||||||
|
go build -o bin/moonminers .
|
||||||
|
|
||||||
|
# Server on :3000 that reloads templates and static files from disk; CSS rebuilds on change.
|
||||||
|
dev: $(TAILWIND)
|
||||||
|
$(TAILWIND) -i web/css/app.css -o $(CSS) --watch=always & \
|
||||||
|
trap 'kill $$!' EXIT; MM_DEV=1 go run . serve
|
||||||
|
|
||||||
|
css: $(TAILWIND)
|
||||||
|
$(TAILWIND) -i web/css/app.css -o $(CSS) --minify
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
go run . migrate
|
||||||
|
|
||||||
|
load-config:
|
||||||
|
go run . load-config
|
||||||
|
|
||||||
|
check:
|
||||||
|
test -z "$$(gofmt -l .)" && go vet ./...
|
||||||
|
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
|
||||||
|
|
||||||
|
$(TAILWIND):
|
||||||
|
mkdir -p bin
|
||||||
|
curl -fsSL -o $@ https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
|
||||||
|
chmod +x $@
|
||||||
@@ -1,45 +1,252 @@
|
|||||||
# Moon Miners
|
# Moon Miners
|
||||||
|
|
||||||
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
|
A browser mining game. Players own moons (staking sources) with resource wells, buy and
|
||||||
|
upgrade mining tools, stake them on wells, claim resources when the stake completes, and sell
|
||||||
|
resources for credits (₥). Drill cycles last seconds, so play is active. The refinery turns
|
||||||
|
resources into refined items on a timer; those sell for more and are needed as parts for
|
||||||
|
higher tool upgrades.
|
||||||
|
|
||||||
- `server/`: Go server: the JSON API (`server/api.md`) and the admin pages (`/utils`).
|
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
|
||||||
- `client/`: Defold client, for desktop and the browser (WebAssembly).
|
a three.js view of the selected moon, generated procedurally from its id. Game rules run in
|
||||||
|
Go, one Postgres transaction per action. The page doesn't reload on timers: countdowns tick
|
||||||
|
in the browser and enable each Claim button when its drill should be done, and the server
|
||||||
|
decides what is actually ready when it's clicked.
|
||||||
|
|
||||||
## Server
|
Players join anonymously: on the first visit the server creates a player and stores its id in
|
||||||
|
a cookie. No wallet or sign-up needed.
|
||||||
|
|
||||||
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
## Requirements
|
||||||
`server/README.md`).
|
|
||||||
|
|
||||||
```bash
|
- Go 1.25+
|
||||||
podman-compose up -d db # or: docker compose up -d db
|
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
|
||||||
cd server
|
- Node.js, only for `make check` (syntax-checks the JS)
|
||||||
make migrate
|
|
||||||
make dev # http://localhost:3000: the game (once built, below); /utils: the admin
|
`make` downloads the Tailwind standalone CLI into `bin/` on first use.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
The server reads `.env` in the project root (gitignored, never commit it). Real environment
|
||||||
|
variables take precedence.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
DB_USER=moonminers # app user: read/write access to the tables
|
||||||
|
DB_PASSWORD=moonminers
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_DATABASE=moonminers
|
||||||
|
ADMIN_DB_USER=postgres # superuser, only used by `migrate`
|
||||||
|
ADMIN_DB_PASSWORD=postgres
|
||||||
```
|
```
|
||||||
|
|
||||||
After pulling changes or editing `server/config/game.json`, run `make migrate` again before
|
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates
|
||||||
starting the server: it applies new migrations and syncs the tools and prices with the
|
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated
|
||||||
config (the server alone doesn't).
|
player ids allowed on the admin pages; unset means no admins), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit new
|
||||||
|
players by the last `X-Forwarded-For` address instead of the connection's; new players are
|
||||||
|
limited to 10 a minute per IP).
|
||||||
|
|
||||||
## Defold client
|
## Local development
|
||||||
|
|
||||||
Needs the Defold editor (https://defold.com/download/) or Java 21+. The Makefile downloads
|
1. Create `.env` as above.
|
||||||
Defold's command-line builder (`bob.jar`) and runs it with the Java the editor ships, found
|
2. Start Postgres and set up the database (roles, schema, migrations, store items from
|
||||||
in the usual places on Linux, macOS and Windows (`make DEFOLD=<folder holding its packages>`
|
`config/game.json`):
|
||||||
for another place, `JAVA=<path>` for your own Java); without an editor, `java` from PATH.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
podman-compose up -d db # or: docker compose up -d db
|
||||||
make run # desktop build, plays against http://localhost:3000
|
make migrate
|
||||||
make web # browser build into dist/web/MoonMiners/
|
```
|
||||||
```
|
|
||||||
|
|
||||||
The server serves the browser build at http://localhost:3000 (from
|
3. Run the server and open http://localhost:3000:
|
||||||
`client/dist/web/MoonMiners`, or `MM_WEB_DIR`). It looks for the build when it starts:
|
|
||||||
after the first `make web`, restart the server; later builds only need a page reload.
|
|
||||||
Without a build, `/` goes to the admin. The desktop and browser games keep separate saved
|
|
||||||
players.
|
|
||||||
|
|
||||||
## Deploy
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
|
Template, CSS and JS changes show up on reload; Go changes need a restart.
|
||||||
|
|
||||||
|
Other commands: `make build` (CSS + `bin/moonminers`), `make check` (gofmt, vet, the template
|
||||||
|
check in `templates_test.go`, which renders every page against sample game states without a
|
||||||
|
database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and
|
||||||
|
`terrain.js` agree on moon looks), `make load-config` (sync the store items in the database with `config/game.json`).
|
||||||
|
|
||||||
|
The binary's commands: `serve` (default), `migrate`, `load-config`, `sim [file]` (print the
|
||||||
|
economy analysis and a 4-hour greedy-player sim for the game config, or for a config file),
|
||||||
|
and `moon-traits` (JSON on stdin; used by the moon traits check).
|
||||||
|
Reset the local database: `podman-compose down -v && podman-compose up -d db && make migrate`.
|
||||||
|
|
||||||
|
## Database changes
|
||||||
|
|
||||||
|
`migrate` runs on every deploy and must be safe to repeat. It creates the `devs` role and the
|
||||||
|
app user, creates the schema from `sql/tables.sql` and `sql/data.sql` on an empty database,
|
||||||
|
applies `sql/migrations/*.sql` in name order, grants the app user access, and syncs the
|
||||||
|
store items with the game config.
|
||||||
|
|
||||||
|
For a schema change, update `sql/tables.sql` (fresh databases) and add an idempotent
|
||||||
|
`sql/migrations/NNN-name.sql` (existing databases: `if not exists`, `if exists`). A fresh
|
||||||
|
database and one migrated from the pre-Go schema through every migration must end up with the
|
||||||
|
same schema (compare `pg_dump --schema-only`). The migrations so far: 001 drops the old config
|
||||||
|
tables, 002 moon settings, 003 config overrides, 004 live ops, 005 refinery, 006 player
|
||||||
|
admin, 007 drill cycles in seconds, 008 security fixes (UTC defaults, one claim per stake,
|
||||||
|
non-negative well supply), 009 schema cleanup (old columns, moon images), 010 drill
|
||||||
|
attachments and advisors, 011 market pressure, 012 onboarding, 013 moon type
|
||||||
|
(`staking_source.kind`, now that moons get generated names).
|
||||||
|
|
||||||
|
## Game config
|
||||||
|
|
||||||
|
`config/game.json` holds the balance numbers (starting credits, resources and tools, moon
|
||||||
|
price, well supply range, `wellCountWeights`: how many wells a new moon gets, resource prices
|
||||||
|
in credits per unit), the store items with their
|
||||||
|
upgrade tiers (tiers can also need refined `parts`; a drill cycle is `completionTimeInSecs`),
|
||||||
|
the moon types (a name, a placeholder description: the look is procedural and the
|
||||||
|
description is generated when the moon is bought, see `moondesc.go`, and `affinities`:
|
||||||
|
weights of the resources its wells hold, 1 if unlisted), moon names (`moonNames`: roots, and
|
||||||
|
weights for the suffix styles: Roman numeral, Greek letter, catalogue number, letter and
|
||||||
|
number; a player never gets the same name twice), and the refinery
|
||||||
|
(`refinery`: slot prices, max batch size, `bulkExponent` (a batch of n takes
|
||||||
|
seconds × n^bulkExponent, so bigger batches are faster per unit), and the refined items with
|
||||||
|
their recipe, seconds per unit and sell price). Market prices move: every 5 minutes each resource and refined item
|
||||||
|
drifts within ±`marketVolatility` (default 15%) of its configured price, from a deterministic
|
||||||
|
function of name and time (`market.go`), which also gives the 24h charts; sells use it.
|
||||||
|
A player's own selling lowers their price a little (`marketPressure`): each sale adds its
|
||||||
|
base value to their pressure on the item, which halves every `halfLifeSecs`, and the price
|
||||||
|
factor is `floor + (1 − floor) × e^(−pressure / scale)`, so it flattens out at `floor`.
|
||||||
|
Drill attachments (`attachments`: `slotsPerTool`, and per attachment a `price`, an `image`
|
||||||
|
and `speed`/`yield` multipliers) are bought in the Store and equipped on a drill from its
|
||||||
|
panel in Equipment, where idle drills and spare attachments can also be sold back for
|
||||||
|
`equipmentResale.factor` of their credit price (a drill: its price plus its upgrades; refined
|
||||||
|
parts aren't refunded). A moon sells back (`moonResale`) for what's left in its wells at market
|
||||||
|
price × `factor`, at most `maxRatio` × the price of a replacement moon, no sooner than
|
||||||
|
`minHoldSecs` after buying it, and not while a drill on it is running. Advisors (`advisors`: `seatPrices` for the 2nd, 3rd, ... seat, and per
|
||||||
|
advisor a hire `price` and `speed`/`yield`/`sell`/`refine` multipliers) boost the whole
|
||||||
|
account. A player's multipliers multiply together and each kind is capped by `bonusCaps`;
|
||||||
|
live events multiply on top. They count when an action runs, so a stake keeps the bonuses
|
||||||
|
it started with (`bonus.go`).
|
||||||
|
It's embedded in the binary, so changes need a rebuild.
|
||||||
|
Store items are also kept in the database (`migrate` and `make load-config` sync them;
|
||||||
|
items removed from the file are deleted unless a player owns one).
|
||||||
|
|
||||||
|
Admins can change these numbers without a deploy on `/utils/economy` (see Admin panels).
|
||||||
|
|
||||||
|
## Admin panels
|
||||||
|
|
||||||
|
Admins are the player ids listed in `ADMIN_USER_IDS` (comma-separated). With `MM_DEV=1`
|
||||||
|
everyone is an admin, so never set it in production. Other players get a 403 (the admin pages
|
||||||
|
show "missing permission"). Every admin page
|
||||||
|
shares one nav: Players · Moons · Economy · Live ops · Game.
|
||||||
|
|
||||||
|
| Page | What it's for |
|
||||||
|
|---|---|
|
||||||
|
| `/utils` | Redirects to `/utils/players`. |
|
||||||
|
| `/utils/players` | Find players (id prefix or name), sort by last seen, activity, newest, richest. |
|
||||||
|
| `/utils/players/{id}` | One player: balance, resources, refined items, moons and wells, tools and drills, event history (older pages at `/utils/players/{id}/events?page=n`). Grant/remove credits, grant resources and tools, finish running drills, reset, ban/unban. |
|
||||||
|
| `/utils/moons` | Moon lab: live 3D preview with sliders for terrain, craters, colors, bloom, atmosphere and rotation. "Save for the game" applies the look to every moon. |
|
||||||
|
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with upgrade tiers), resources, refined items, market, attachments, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
|
||||||
|
| `/utils/liveops` | Dashboard, timed events and announcements. |
|
||||||
|
|
||||||
|
Actions (all `POST`, confirmed in the page's own dialog):
|
||||||
|
|
||||||
|
| Endpoint | Actions |
|
||||||
|
|---|---|
|
||||||
|
| `/utils/players/{id}/{action}` | `credits`, `resources`, `tool`, `finish-stakes`, `reset`, `ban`, `unban` |
|
||||||
|
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
|
||||||
|
| `/utils/liveops/{what}` | `events/save`, `announcements/save` |
|
||||||
|
| `/utils/liveops/{what}/{id}/{action}` | toggle or delete an event, delete an announcement |
|
||||||
|
| `/utils/{action}` | `clear-data`, `load-config` (older endpoints, no page) |
|
||||||
|
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` (admins) saves it |
|
||||||
|
|
||||||
|
Request examples are in `test-endpoints.restclient`.
|
||||||
|
|
||||||
|
### Players
|
||||||
|
|
||||||
|
Every admin action is logged in `event_log` with the admin's id (`admin_*` kinds, which the
|
||||||
|
live-ops stats ignore). Banned players see "This account is suspended" and their actions get a
|
||||||
|
403.
|
||||||
|
|
||||||
|
### Moons
|
||||||
|
|
||||||
|
Each moon's look is generated from its id (terrain technique, noise, craters, colors). The lab
|
||||||
|
edits the shared settings that every moon starts from; they're stored as one JSON row in
|
||||||
|
`moon_settings` and merged over the defaults in `web/static/js/terrain.js`.
|
||||||
|
|
||||||
|
### Economy
|
||||||
|
|
||||||
|
A form for the constants, resource prices, store items and upgrade tiers (including parts),
|
||||||
|
and the refinery, plus a raw JSON editor for every field. The analysis updates as you type:
|
||||||
|
income and payback per tool and upgrade, refining gain per recipe, red flags, and a 72-hour
|
||||||
|
greedy-player sim. Each save stores the values that differ from `config/game.json` as a new row
|
||||||
|
in `config_override`; the latest row is in effect (merged over the file, reloaded every 30 s)
|
||||||
|
and store items are re-synced.
|
||||||
|
|
||||||
|
### Live ops
|
||||||
|
|
||||||
|
- Dashboard: players, daily actives, D1/D7 retention, actions, credits earned vs spent,
|
||||||
|
resources claimed. Built from `event_log`, which every game action writes to.
|
||||||
|
- Timed events: yield, sell price or mining speed multipliers, applied server-side.
|
||||||
|
- Announcements.
|
||||||
|
|
||||||
|
Live events and announcements show in a banner at the top of the game. Times are UTC.
|
||||||
|
|
||||||
|
## Interface
|
||||||
|
|
||||||
|
Server-rendered regions swapped by htmx, which morphs them into the page with the idiomorph
|
||||||
|
extension (0BSD, `web/static/vendor/idiomorph`) so elements keep their state; the JS only
|
||||||
|
adds display: `main.js` (htmx glue,
|
||||||
|
viewport, countdowns), `fx.js` (effects per action result), `sfx.js` (sound, below),
|
||||||
|
`tabs.js` (sliding tab highlight), `enter.js` (entrance animations when a view changes),
|
||||||
|
`market.js` (chart crosshair), `ui.js` (confirm dialog and toasts, shared with the admin
|
||||||
|
pages), `scene.js`/`terrain.js`/`moon-worker.js` (the moon).
|
||||||
|
|
||||||
|
## Sound
|
||||||
|
|
||||||
|
`web/static/js/sfx.js` plays sound effects with the Web Audio API: short clips from Kenney's
|
||||||
|
CC0 packs (trimmed and loudness-matched, listed in `web/static/assets/sfx/CREDITS.txt`) and
|
||||||
|
a few sounds synthesized with ZzFX (MIT, `web/static/vendor/zzfx`). The speaker button in the
|
||||||
|
header mutes them (remembered per browser); they are also muted while the tab is hidden.
|
||||||
|
`window.mmAudio.mute()` / `unmute()` mute from outside (e.g. during an ad) without touching
|
||||||
|
the player's setting. Set `localStorage["mm-sfx-debug"] = "1"` to log every sound played.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Players are identified only by the `mm_user` cookie (HttpOnly, SameSite=Lax). New players
|
||||||
|
are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind a proxy).
|
||||||
|
- Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
|
||||||
|
so oversized ones get a 413.
|
||||||
|
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
|
||||||
|
- Game actions validate ids, lock what they read and keep balances and supplies at 0 or more
|
||||||
|
(schema checks back this up); a stake is claimed at most once (unique index).
|
||||||
|
- Errors that aren't rule violations are logged and answered with a generic 500.
|
||||||
|
|
||||||
|
## Deployment (Dokploy)
|
||||||
|
|
||||||
|
`docker-compose.prod.yml` runs `db` (Postgres 16, data in the `db-data` volume), `migrate`
|
||||||
|
(schema and migrations, runs before the app on every deploy) and `app` (port 3000).
|
||||||
|
|
||||||
|
First deploy:
|
||||||
|
|
||||||
|
1. Dokploy: create a **Docker Compose** service from this repository, compose path
|
||||||
|
`docker-compose.prod.yml`.
|
||||||
|
2. **Environment**:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
|
||||||
|
DB_PASSWORD=... # strong; app user "moonminers"
|
||||||
|
ADMIN_USER_IDS= # fill in at step 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Never set `MM_DEV` in production: it makes every player an admin.
|
||||||
|
3. **Domains**: your domain → service `app`, port `3000`, HTTPS on.
|
||||||
|
4. Coming from the old (Next.js) version and want a clean start? Stop the service and delete
|
||||||
|
the `db-data` volume first. Otherwise `migrate` upgrades the old database in place.
|
||||||
|
5. Deploy, open the site once, and copy your player id: the value of the `mm_user` cookie
|
||||||
|
(browser dev tools → Storage/Application → Cookies). Put it in `ADMIN_USER_IDS`
|
||||||
|
(comma-separate several) and redeploy. The admin pages are at `/utils`.
|
||||||
|
|
||||||
|
Updates: push to the branch Dokploy tracks and deploy; `migrate` applies new migrations.
|
||||||
|
Back up `db-data` with `pg_dump` or Dokploy's volume backups.
|
||||||
|
|
||||||
|
To test the production stack locally:
|
||||||
|
`POSTGRES_PASSWORD=x DB_PASSWORD=y podman-compose -p mmprod -f docker-compose.prod.yml up --build`
|
||||||
|
(add a `ports: ["3100:3000"]` override for `app` to reach it).
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
See `test-endpoints.restclient` (Emacs restclient format).
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdvisorConfig struct {
|
||||||
|
// Price of the 2nd, 3rd, ... seat. Players start with one; the cap is 1 + len.
|
||||||
|
SeatPrices []float64 `json:"seatPrices"`
|
||||||
|
Items []Advisor `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Advisor struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"` // the specialty, e.g. "Commodities Broker"
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image"` // portrait, in web/static/assets/advisors
|
||||||
|
Price float64 `json:"price"` // to hire
|
||||||
|
Speed float64 `json:"speed,omitempty"` // multipliers; unset is 1
|
||||||
|
Yield float64 `json:"yield,omitempty"` //
|
||||||
|
Sell float64 `json:"sell,omitempty"` //
|
||||||
|
Refine float64 `json:"refine,omitempty"` //
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GameConfig) advisor(name string) *Advisor {
|
||||||
|
for i := range c.Advisors.Items {
|
||||||
|
if c.Advisors.Items[i].Name == name {
|
||||||
|
return &c.Advisors.Items[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effect describes the bonus, e.g. "+8% sell prices".
|
||||||
|
func (a *Advisor) Effect() string {
|
||||||
|
var parts []string
|
||||||
|
for _, e := range []struct {
|
||||||
|
m float64
|
||||||
|
what string
|
||||||
|
}{{a.Speed, "drill speed"}, {a.Yield, "yield"}, {a.Sell, "sell prices"}, {a.Refine, "refinery speed"}} {
|
||||||
|
if e.m > 1 {
|
||||||
|
parts = append(parts, pctText(e.m)+" "+e.what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockAdvisors locks the player's row, which serializes their advisor actions, and returns
|
||||||
|
// how many seats they own.
|
||||||
|
func lockAdvisors(ctx context.Context, tx pgx.Tx, userID string) (int, error) {
|
||||||
|
var seats int
|
||||||
|
err := tx.QueryRow(ctx,
|
||||||
|
"select advisor_seats from users where id = $1 for no key update", userID).Scan(&seats)
|
||||||
|
return seats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// hireAdvisor hires an advisor into a free seat.
|
||||||
|
func hireAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
|
||||||
|
a := cfg().advisor(name)
|
||||||
|
if a == nil {
|
||||||
|
return gameErr("Unknown advisor")
|
||||||
|
}
|
||||||
|
return inTx(ctx, db, func(tx pgx.Tx) error {
|
||||||
|
seats, err := lockAdvisors(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 {
|
||||||
|
return gameErr("%s already works for you", a.Name)
|
||||||
|
}
|
||||||
|
if hired >= seats {
|
||||||
|
return gameErr("All advisor seats are taken. Buy another seat first")
|
||||||
|
}
|
||||||
|
if err := debit(ctx, tx, userID, a.Price); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor) values ($1, $2)", userID, a.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buyAdvisorSeat adds a seat at the next seat's price.
|
||||||
|
func buyAdvisorSeat(ctx context.Context, db *pgxpool.Pool, userID string) (int, error) {
|
||||||
|
var seats int
|
||||||
|
err := inTx(ctx, db, func(tx pgx.Tx) error {
|
||||||
|
var err error
|
||||||
|
if seats, err = lockAdvisors(ctx, tx, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prices := cfg().Advisors.SeatPrices
|
||||||
|
if seats-1 >= len(prices) {
|
||||||
|
return gameErr("You have every seat already")
|
||||||
|
}
|
||||||
|
price := prices[seats-1]
|
||||||
|
if err := debit(ctx, tx, userID, price); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seats++
|
||||||
|
_, err = tx.Exec(ctx, "update users set advisor_seats = $2 where id = $1", userID, seats)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return logEvent(ctx, tx, userID, "buy_seat", map[string]any{"seats": seats, "price": price})
|
||||||
|
})
|
||||||
|
return seats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ view
|
||||||
|
|
||||||
|
type AdvisorsPage struct {
|
||||||
|
Cards []AdvisorCard
|
||||||
|
Seats, Hired int
|
||||||
|
MaxSeats int
|
||||||
|
NextSeat int // number of the next seat to buy
|
||||||
|
NextSeatPrice float64 // 0 at the cap
|
||||||
|
CanBuySeat bool
|
||||||
|
Bonuses []BonusRow // the combined advisor bonus, capped
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvisorCard struct {
|
||||||
|
*Advisor
|
||||||
|
Hired bool
|
||||||
|
CanAfford bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) advisorsPage() *AdvisorsPage {
|
||||||
|
c := cfg()
|
||||||
|
p := &AdvisorsPage{Seats: s.AdvisorSeats, Hired: len(s.Advisors), MaxSeats: 1 + len(c.Advisors.SeatPrices),
|
||||||
|
NextSeat: s.AdvisorSeats + 1,
|
||||||
|
Bonuses: s.BonusRows()}
|
||||||
|
if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) {
|
||||||
|
p.NextSeatPrice = c.Advisors.SeatPrices[n]
|
||||||
|
p.CanBuySeat = s.Balance >= p.NextSeatPrice
|
||||||
|
}
|
||||||
|
for i := range c.Advisors.Items {
|
||||||
|
a := &c.Advisors.Items[i]
|
||||||
|
card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price}
|
||||||
|
for _, h := range s.Advisors {
|
||||||
|
card.Hired = card.Hired || h == a.Name
|
||||||
|
}
|
||||||
|
p.Cards = append(p.Cards, card)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ""
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// JSON API for the game client (Defold): the state the views build (views.go) and the actions, run through runAction (server.go). Times are unix
|
// 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.
|
// milliseconds (null when unset), durations seconds; the client formats. See api.md.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -33,22 +34,19 @@ type APIState struct {
|
|||||||
Resources []APIAmount `json:"resources"`
|
Resources []APIAmount `json:"resources"`
|
||||||
Refined []APIAmount `json:"refined"`
|
Refined []APIAmount `json:"refined"`
|
||||||
MoonPrice float64 `json:"moonPrice"`
|
MoonPrice float64 `json:"moonPrice"`
|
||||||
MoonGrade string `json:"moonGrade"` // the grade buy-moon gets without one (moonPrice is its price)
|
|
||||||
Unlocked map[string]bool `json:"unlocked"`
|
Unlocked map[string]bool `json:"unlocked"`
|
||||||
Shine []string `json:"shine"` // revealed tabs not opened yet
|
Shine []string `json:"shine"` // revealed tabs not opened yet
|
||||||
Ready int `json:"ready"` // finished drills, all moons
|
Ready int `json:"ready"` // finished drills, all moons
|
||||||
Idle int `json:"idle"` // tools not mining
|
Idle int `json:"idle"` // tools not mining
|
||||||
NextEnd *int64 `json:"nextEnd"`
|
NextEnd *int64 `json:"nextEnd"`
|
||||||
Moons []APIMoonListItem `json:"moons"` // active moons
|
Moons []APIMoonListItem `json:"moons"` // active moons
|
||||||
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first: one page (page=)
|
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first
|
||||||
PastPage APIPastPage `json:"pastPage"`
|
|
||||||
Tools []APITool `json:"tools"`
|
Tools []APITool `json:"tools"`
|
||||||
Stakes []APIStake `json:"stakes"` // every running or unclaimed drill
|
Stakes []APIStake `json:"stakes"` // every running or unclaimed drill
|
||||||
Moon *APIMoon `json:"moon"` // s=moons with a moon
|
Moon *APIMoon `json:"moon"` // s=moons with a moon
|
||||||
Well *APIWell `json:"well"` // s=moons with a well selected
|
Well *APIWell `json:"well"` // s=moons with a well selected
|
||||||
Market *APIMarket `json:"market"` // once the market is unlocked
|
Market *APIMarket `json:"market"` // once the market is unlocked
|
||||||
Banner []APIBanner `json:"banner"`
|
Banner []APIBanner `json:"banner"`
|
||||||
Admin bool `json:"admin"` // may use admin-grant
|
|
||||||
|
|
||||||
// Always present (api_sections.go).
|
// Always present (api_sections.go).
|
||||||
ShowNav bool `json:"showNav"` // the section tabs show (not while Moons would be the only one)
|
ShowNav bool `json:"showNav"` // the section tabs show (not while Moons would be the only one)
|
||||||
@@ -77,17 +75,6 @@ type APIView struct {
|
|||||||
Past bool `json:"past"`
|
Past bool `json:"past"`
|
||||||
Pick string `json:"pick"`
|
Pick string `json:"pick"`
|
||||||
Drill string `json:"drill"`
|
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 {
|
type APIAmount struct {
|
||||||
@@ -115,8 +102,6 @@ type APIMoonListItem struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Grade string `json:"grade"`
|
|
||||||
MaxSupply float64 `json:"maxSupply"` // the biggest well of its grade: a full bar
|
|
||||||
Rating *APIRating `json:"rating"`
|
Rating *APIRating `json:"rating"`
|
||||||
Wells []APIWellSupply `json:"wells"`
|
Wells []APIWellSupply `json:"wells"`
|
||||||
Drills int `json:"drills"`
|
Drills int `json:"drills"`
|
||||||
@@ -131,41 +116,40 @@ type APIMoonListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type APITool struct {
|
type APITool struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
StoreItemID int `json:"storeItemId"`
|
StoreItemID int `json:"storeItemId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Code string `json:"code"` // model code, e.g. "PD-1"
|
Code string `json:"code"` // model code, e.g. "PD-1"
|
||||||
Image string `json:"image"` // static URL
|
Image string `json:"image"` // static URL
|
||||||
YieldLevel int `json:"yieldLevel"` // levels on its upgrade tracks (0: base)
|
Tier int `json:"tier"`
|
||||||
CycleLevel int `json:"cycleLevel"`
|
MaxTier int `json:"maxTier"`
|
||||||
Reach int `json:"reach"` // wells one cycle mines
|
Reach int `json:"reach"` // wells one cycle mines
|
||||||
CycleSecs float64 `json:"cycleSecs"` // a stake now, with bonuses and live events (tenths)
|
CycleSecs int `json:"cycleSecs"` // a stake now, with bonuses and live events
|
||||||
Yield float64 `json:"yield"`
|
Yield float64 `json:"yield"`
|
||||||
StakeID string `json:"stakeId,omitempty"` // mining, if set
|
Attachments []string `json:"attachments"`
|
||||||
|
StakeID string `json:"stakeId,omitempty"` // mining, if set
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIStake struct {
|
type APIStake struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
MoonID string `json:"moonId"`
|
MoonID string `json:"moonId"`
|
||||||
WellID string `json:"wellId"` // the first well it mines
|
WellID string `json:"wellId"` // the first well it mines
|
||||||
ToolID string `json:"toolId"`
|
ToolID string `json:"toolId"`
|
||||||
Resource string `json:"resource"`
|
Resource string `json:"resource"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
Reach int `json:"reach"`
|
Reach int `json:"reach"`
|
||||||
Start int64 `json:"start"`
|
Start int64 `json:"start"`
|
||||||
End int64 `json:"end"`
|
End int64 `json:"end"`
|
||||||
Secs float64 `json:"durationSecs"`
|
Secs int `json:"durationSecs"`
|
||||||
Ready bool `json:"ready"`
|
Ready bool `json:"ready"`
|
||||||
Take float64 `json:"take"` // what claiming it would get now
|
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.
|
Covers []string `json:"covers,omitempty"` // multi-well drills: the wells it mines
|
||||||
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
|
// 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).
|
// this well, and whether that is its full amount (ok, short, none).
|
||||||
Gets *float64 `json:"gets,omitempty"`
|
Gets *float64 `json:"gets,omitempty"`
|
||||||
Here *float64 `json:"here,omitempty"`
|
Here *float64 `json:"here,omitempty"`
|
||||||
State string `json:"state,omitempty"`
|
State string `json:"state,omitempty"`
|
||||||
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 Y1 C2: +2"
|
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 T0: +2"
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIDrillGroup struct {
|
type APIDrillGroup struct {
|
||||||
@@ -181,8 +165,6 @@ type APIMoon struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Grade string `json:"grade"`
|
|
||||||
MaxSupply float64 `json:"maxSupply"` // the biggest well of its grade: a full bar
|
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Rating *APIRating `json:"rating"`
|
Rating *APIRating `json:"rating"`
|
||||||
Finished bool `json:"finished"`
|
Finished bool `json:"finished"`
|
||||||
@@ -204,17 +186,6 @@ type APIWellRow struct {
|
|||||||
Remain float64 `json:"remain"`
|
Remain float64 `json:"remain"`
|
||||||
DryAfter int `json:"dryAfter"` // the claim that empties it (0: none)
|
DryAfter int `json:"dryAfter"` // the claim that empties it (0: none)
|
||||||
DryAt *int64 `json:"dryAt"`
|
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"`
|
|
||||||
// FullyCommitted: its drills will take all it has left (remain 0); a one-well drill
|
|
||||||
// can't be deployed on it (stake refused, "Nothing left to mine on this well").
|
|
||||||
FullyCommitted bool `json:"fullyCommitted"`
|
|
||||||
StatusText string `json:"statusText"`
|
|
||||||
NoteText string `json:"noteText"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIWell struct {
|
type APIWell struct {
|
||||||
@@ -226,19 +197,18 @@ type APIWell struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type APIToolGroup struct {
|
type APIToolGroup struct {
|
||||||
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
|
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
|
||||||
StoreItemID int `json:"storeItemId"`
|
StoreItemID int `json:"storeItemId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
YieldLevel int `json:"yieldLevel"`
|
Tier int `json:"tier"`
|
||||||
CycleLevel int `json:"cycleLevel"`
|
Count int `json:"count"`
|
||||||
Count int `json:"count"`
|
CycleSecs int `json:"cycleSecs"`
|
||||||
CycleSecs float64 `json:"cycleSecs"`
|
Yield float64 `json:"yield"`
|
||||||
Yield float64 `json:"yield"`
|
Reach int `json:"reach"`
|
||||||
Reach int `json:"reach"`
|
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
|
||||||
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
|
Free float64 `json:"free"`
|
||||||
Free float64 `json:"free"`
|
Short bool `json:"short"`
|
||||||
Short bool `json:"short"`
|
Attachments []string `json:"attachments"`
|
||||||
Blocked bool `json:"blocked"` // here is 0: the stake is refused
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIBetterWell struct {
|
type APIBetterWell struct {
|
||||||
@@ -252,7 +222,7 @@ type APIBetterWell struct {
|
|||||||
type APIMarket struct {
|
type APIMarket struct {
|
||||||
Quotes []APIQuote `json:"quotes"`
|
Quotes []APIQuote `json:"quotes"`
|
||||||
Selected string `json:"selected"`
|
Selected string `json:"selected"`
|
||||||
Total float64 `json:"total"` // value of everything held, at the quotes
|
Total float64 `json:"total"` // value of everything held
|
||||||
Held int `json:"held"` // kinds held
|
Held int `json:"held"` // kinds held
|
||||||
Best string `json:"best"` // biggest 24h gain
|
Best string `json:"best"` // biggest 24h gain
|
||||||
Worst string `json:"worst"` // biggest 24h loss
|
Worst string `json:"worst"` // biggest 24h loss
|
||||||
@@ -260,43 +230,27 @@ type APIMarket struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type APIQuote struct {
|
type APIQuote struct {
|
||||||
Item string `json:"item"`
|
Item string `json:"item"`
|
||||||
Refined bool `json:"refined"`
|
Refined bool `json:"refined"`
|
||||||
Balance float64 `json:"balance"`
|
Balance float64 `json:"balance"`
|
||||||
Price float64 `json:"price"` // the shared quote: rate × boost, the same for every player
|
Price float64 `json:"price"` // one unit sells for now: rate × boost × advisor × factor
|
||||||
Rate float64 `json:"rate"`
|
Rate float64 `json:"rate"`
|
||||||
Boost float64 `json:"boost"`
|
Boost float64 `json:"boost"`
|
||||||
// Personal, on the payout only: the sell bonus (advisors), the selling pressure and its
|
|
||||||
// factor now.
|
|
||||||
Advisor float64 `json:"advisor"`
|
Advisor float64 `json:"advisor"`
|
||||||
Pressure float64 `json:"pressure"`
|
Pressure float64 `json:"pressure"`
|
||||||
Factor float64 `json:"factor"`
|
Factor float64 `json:"factor"`
|
||||||
Unit float64 `json:"unit"` // what one unit pays this player now: price × advisor × factor
|
|
||||||
Open float64 `json:"open"` // market rate 24h ago
|
Open float64 `json:"open"` // market rate 24h ago
|
||||||
Change float64 `json:"change"` // percent over 24h
|
Change float64 `json:"change"` // percent over 24h
|
||||||
High float64 `json:"high"`
|
High float64 `json:"high"`
|
||||||
Low float64 `json:"low"`
|
Low float64 `json:"low"`
|
||||||
Value float64 `json:"value"` // balance × price
|
Value float64 `json:"value"`
|
||||||
// For the sell ticket: what a unit pays this player before selling pressure (price ×
|
// For the sell ticket: the price before selling pressure, the base price (pressure is
|
||||||
// advisor), the base price (pressure is counted in base-price credits) and what selling
|
// counted in base-price credits) and what selling everything held pays now.
|
||||||
// everything held pays them now.
|
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
Unpressed float64 `json:"unpressed"`
|
Unpressed float64 `json:"unpressed"`
|
||||||
Base float64 `json:"base"`
|
Base float64 `json:"base"`
|
||||||
SellAll float64 `json:"sellAll"`
|
SellAll float64 `json:"sellAll"`
|
||||||
Spark []float64 `json:"spark,omitempty"` // s=market: 24h, every 3rd point (15 minutes) and now
|
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
|
|
||||||
// (the player's recent selling lowers their payout noticeably), pressureText "−4%";
|
|
||||||
// boosted (a live event, on the quote), boostText "×2"; advised (an advisor sell bonus,
|
|
||||||
// on the payout), 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).
|
// APIBanner is a banner line: an announcement or a live event (id matches liveEvents[].id).
|
||||||
@@ -332,10 +286,18 @@ func apiAmounts(rows []ResourceBalance) []APIAmount {
|
|||||||
return out
|
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 {
|
func apiStake(st StakeRow, projected bool) APIStake {
|
||||||
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
|
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(),
|
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
|
||||||
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers, ReachText: st.ReachText()}
|
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers}
|
||||||
if projected {
|
if projected {
|
||||||
gets, here := st.Gets, st.Here
|
gets, here := st.Gets, st.Here
|
||||||
a.Gets, a.Here, a.State, a.Fate = &gets, &here, st.State, st.Fate()
|
a.Gets, a.Here, a.State, a.Fate = &gets, &here, st.State, st.Fate()
|
||||||
@@ -355,7 +317,7 @@ func apiGroup(g DrillGroup) APIDrillGroup {
|
|||||||
func apiMoonList(rows []MoonListRow) []APIMoonListItem {
|
func apiMoonList(rows []MoonListRow) []APIMoonListItem {
|
||||||
out := []APIMoonListItem{}
|
out := []APIMoonListItem{}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
m := APIMoonListItem{ID: r.ID, Name: r.Name, Kind: r.Kind, Grade: r.Grade, MaxSupply: r.GradeConfig().WellSupplyMax, Rating: apiRating(r.Rating), Wells: []APIWellSupply{},
|
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(),
|
Drills: r.Drills, Ready: r.Ready, Over: r.Over, Selected: r.Selected, CreatedAt: r.CreatedAt.UnixMilli(),
|
||||||
Finished: r.Finished, FinishedAt: unixMS(r.FinishedAt)}
|
Finished: r.Finished, FinishedAt: unixMS(r.FinishedAt)}
|
||||||
for _, w := range r.Wells {
|
for _, w := range r.Wells {
|
||||||
@@ -374,13 +336,11 @@ func apiMoonList(rows []MoonListRow) []APIMoonListItem {
|
|||||||
func apiState(p *Page, extra ...*Page) *APIState {
|
func apiState(p *Page, extra ...*Page) *APIState {
|
||||||
s, v := p.S, p.V
|
s, v := p.S, p.V
|
||||||
a := &APIState{UserID: s.UserID, Now: s.Now.UnixMilli(),
|
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},
|
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
|
||||||
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(), MoonGrade: s.MoonGrade().Grade,
|
|
||||||
Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd),
|
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{},
|
Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{},
|
||||||
Banner: []APIBanner{}, Admin: p.Admin}
|
Banner: []APIBanner{}}
|
||||||
for _, r := range s.Refined {
|
for _, r := range s.Refined {
|
||||||
a.Refined = append(a.Refined, APIAmount{Name: r.Item, Amount: r.Balance})
|
a.Refined = append(a.Refined, APIAmount{Name: r.Item, Amount: r.Balance})
|
||||||
}
|
}
|
||||||
@@ -405,8 +365,8 @@ func apiState(p *Page, extra ...*Page) *APIState {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
|
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
|
||||||
Image: itemImages + item.Image, YieldLevel: t.YieldLevel, CycleLevel: t.CycleLevel,
|
Image: itemImages + item.ImageAt(t.Tier), Tier: t.Tier, MaxTier: len(item.Upgrades),
|
||||||
Reach: item.ToolReach()}
|
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
|
||||||
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
|
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
|
||||||
if st := s.StakeForTool(t.ID); st != nil {
|
if st := s.StakeForTool(t.ID); st != nil {
|
||||||
tool.StakeID = st.ID
|
tool.StakeID = st.ID
|
||||||
@@ -418,7 +378,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if d := p.Moon; d != nil {
|
if d := p.Moon; d != nil {
|
||||||
m := &APIMoon{ID: d.ID, Name: d.Name, Kind: d.Kind, Grade: d.Grade, MaxSupply: d.GradeConfig().WellSupplyMax, Description: d.Description, Rating: apiRating(d.Rating),
|
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{}}
|
Finished: d.Finished, FinishedOn: d.FinishedOn(), Ready: d.Ready, NextEnd: unixMS(d.NextEnd), Wells: []APIWellRow{}}
|
||||||
if d.Wide != nil {
|
if d.Wide != nil {
|
||||||
g := apiGroup(*d.Wide)
|
g := apiGroup(*d.Wide)
|
||||||
@@ -427,8 +387,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
|
|||||||
for _, w := range d.Wells {
|
for _, w := range d.Wells {
|
||||||
row := APIWellRow{APIWellSupply: APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply},
|
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,
|
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(), FullyCommitted: w.FullyCommitted(), StatusText: w.StatusText(), NoteText: w.NoteText()}
|
|
||||||
for _, st := range w.Wide {
|
for _, st := range w.Wide {
|
||||||
row.Wide = append(row.Wide, st.ID)
|
row.Wide = append(row.Wide, st.ID)
|
||||||
}
|
}
|
||||||
@@ -443,8 +402,8 @@ func apiState(p *Page, extra ...*Page) *APIState {
|
|||||||
well := &APIWell{ID: w.ID, Resource: w.Resource, Rate: w.Rate, Idle: []APIToolGroup{}}
|
well := &APIWell{ID: w.ID, Resource: w.Resource, Rate: w.Rate, Idle: []APIToolGroup{}}
|
||||||
for _, g := range w.Idle {
|
for _, g := range w.Idle {
|
||||||
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
|
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
|
||||||
YieldLevel: g.YieldLevel, CycleLevel: g.CycleLevel, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield,
|
Tier: g.Tier, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield, Reach: max(1, g.Reach), Here: g.Here,
|
||||||
Reach: max(1, g.Reach), Here: g.Here, Free: g.Free, Short: g.Short(), Blocked: g.Blocked()})
|
Free: g.Free, Short: g.Short(), Attachments: apiGear(g.Gear)})
|
||||||
}
|
}
|
||||||
if b := w.Better; b != nil {
|
if b := w.Better; b != nil {
|
||||||
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
|
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
|
||||||
@@ -528,6 +487,7 @@ func (a *APIState) addSection(p *Page) {
|
|||||||
|
|
||||||
// apiMarket maps the market; full adds the chart and the sparklines.
|
// apiMarket maps the market; full adds the chart and the sparklines.
|
||||||
func apiMarket(trade *MarketPage, full bool) *APIMarket {
|
func apiMarket(trade *MarketPage, full bool) *APIMarket {
|
||||||
|
c := cfg()
|
||||||
mk := &APIMarket{Quotes: []APIQuote{}, Total: trade.Total, Held: trade.Held}
|
mk := &APIMarket{Quotes: []APIQuote{}, Total: trade.Total, Held: trade.Held}
|
||||||
if trade.Selected != nil {
|
if trade.Selected != nil {
|
||||||
mk.Selected = trade.Selected.Resource
|
mk.Selected = trade.Selected.Resource
|
||||||
@@ -540,20 +500,11 @@ func apiMarket(trade *MarketPage, full bool) *APIMarket {
|
|||||||
}
|
}
|
||||||
for _, q := range trade.Quotes {
|
for _, q := range trade.Quotes {
|
||||||
aq := APIQuote{Item: q.Resource, Refined: q.Refined, Balance: q.Balance,
|
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, Unit: q.Unit(),
|
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,
|
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()}
|
// A sale is priced at the pressure factor averaged over what it adds (sell in game.go).
|
||||||
if aq.Pressed {
|
aq.SellAll = q.Balance * aq.Unpressed * c.MarketPressure.Average(q.Pressure, q.Balance*aq.Base)
|
||||||
aq.PressureText = q.PressureText()
|
|
||||||
}
|
|
||||||
if aq.Boosted {
|
|
||||||
aq.BoostText = q.BoostText()
|
|
||||||
}
|
|
||||||
if aq.Advised {
|
|
||||||
aq.AdvisorText = q.AdvisorText()
|
|
||||||
}
|
|
||||||
aq.SellAll = q.SellAll()
|
|
||||||
if full {
|
if full {
|
||||||
aq.Spark = spark(q.series)
|
aq.Spark = spark(q.series)
|
||||||
}
|
}
|
||||||
@@ -580,32 +531,17 @@ type APIResponse struct {
|
|||||||
Reveal *APIReveal `json:"reveal,omitempty"`
|
Reveal *APIReveal `json:"reveal,omitempty"`
|
||||||
BuyModal *APIBuyModal `json:"buyModal,omitempty"`
|
BuyModal *APIBuyModal `json:"buyModal,omitempty"`
|
||||||
ClientSeq any `json:"clientSeq,omitempty"` // the request's clientSeq, echoed (a number if it is an integer)
|
ClientSeq any `json:"clientSeq,omitempty"` // the request's clientSeq, echoed (a number if it is an integer)
|
||||||
SaveCode *APISaveCode `json:"saveCode,omitempty"` // save-code: the new code (shown once)
|
|
||||||
Notice *APINotice `json:"notice,omitempty"` // a one-time announcement besides the toast
|
|
||||||
State *APIState `json:"state,omitempty"`
|
State *APIState `json:"state,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// APISaveCode is a player's save code (savecode.go). Code is only set when it was just
|
|
||||||
// made; madeAt is unix ms (0: the player has none).
|
|
||||||
type APISaveCode struct {
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
MadeAt int64 `json:"madeAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// APINotice is a one-time announcement: kind "save-code" points the player to save codes.
|
|
||||||
type APINotice struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type APIToast struct {
|
type APIToast struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Error bool `json:"error"`
|
Error bool `json:"error"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIFX is what the client plays effects from: the action, the moons it mined out and what a
|
// APIFX is what the web client plays effects from (fx.js): the action, the moons it mined
|
||||||
// sale earned.
|
// out and what a sale earned.
|
||||||
type APIFX struct {
|
type APIFX struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Finished []string `json:"finished"`
|
Finished []string `json:"finished"`
|
||||||
@@ -631,7 +567,7 @@ func apiServerError(w http.ResponseWriter, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apiUser finds the player from the X-MM-User header or the mm_user cookie, or creates one
|
// apiUser finds the player from the X-MM-User header or the mm_user cookie, or creates one
|
||||||
// (rate-limited per IP). It echoes the id in the header and cookie; on a refusal it
|
// (as POST /session does). It echoes the id in the header and cookie; on a refusal it
|
||||||
// writes the error response and returns "".
|
// writes the error response and returns "".
|
||||||
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
|
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@@ -641,15 +577,7 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
|
|||||||
userID = c.Value
|
userID = c.Value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
exists := false
|
if !uuidPattern.MatchString(userID) || !userExists(ctx, s.db, userID) {
|
||||||
if uuidPattern.MatchString(userID) {
|
|
||||||
var err error
|
|
||||||
if exists, err = userExists(ctx, s.db, userID); err != nil {
|
|
||||||
apiServerError(w, err)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
if !s.newPlayers.allow(r) {
|
if !s.newPlayers.allow(r) {
|
||||||
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
|
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
|
||||||
return "", false
|
return "", false
|
||||||
@@ -660,10 +588,7 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
created = true
|
created = true
|
||||||
} else if banned, err := isBanned(ctx, s.db, userID); err != nil {
|
} else if isBanned(ctx, s.db, userID) {
|
||||||
apiServerError(w, err)
|
|
||||||
return "", false
|
|
||||||
} else if banned {
|
|
||||||
apiError(w, http.StatusForbidden, "This account is suspended")
|
apiError(w, http.StatusForbidden, "This account is suspended")
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -675,11 +600,11 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
|
|||||||
// apiPage loads the player's state and builds the state for a view. get reads the
|
// apiPage loads the player's state and builds the state for a view. get reads the
|
||||||
// request's include= and catalog= (see apiStateWith).
|
// request's include= and catalog= (see apiStateWith).
|
||||||
func (s *server) apiPage(ctx context.Context, userID string, v View, get func(string) string) (*APIState, error) {
|
func (s *server) apiPage(ctx context.Context, userID string, v View, get func(string) string) (*APIState, error) {
|
||||||
state, v, err := loadView(ctx, s.db, userID, v)
|
state, err := loadState(ctx, s.db, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
page, err := s.apiPageFor(ctx, state, v)
|
page, err := s.apiPageFor(ctx, state, v.resolve(state))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -688,12 +613,6 @@ func (s *server) apiPage(ctx context.Context, userID string, v View, get func(st
|
|||||||
|
|
||||||
// apiPageFor builds the page for a resolved view, with what the server loads for it.
|
// 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) {
|
func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, error) {
|
||||||
if v.Section == "moons" {
|
|
||||||
// The moon view is the only one that shows a description (loadState leaves them out).
|
|
||||||
if err := loadMoonDescription(ctx, s.db, state, v.Moon); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
page := state.page(v)
|
page := state.page(v)
|
||||||
if err := s.loadRanking(ctx, page); err != nil {
|
if err := s.loadRanking(ctx, page); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -706,7 +625,6 @@ func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, e
|
|||||||
// catalog out, catalog=<catalogVersion> leaves it out while it's unchanged.
|
// 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) {
|
func (s *server) apiStateWith(ctx context.Context, page *Page, get func(string) string) (*APIState, error) {
|
||||||
page.Banner = s.banner(ctx)
|
page.Banner = s.banner(ctx)
|
||||||
page.Admin = s.isAdmin(page.S.UserID)
|
|
||||||
var extra []*Page
|
var extra []*Page
|
||||||
for _, section := range apiIncludes(get("include")) {
|
for _, section := range apiIncludes(get("include")) {
|
||||||
if section == page.V.Section {
|
if section == page.V.Section {
|
||||||
@@ -780,7 +698,7 @@ func (s *server) handleAPIState(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, APIResponse{OK: true, UserID: userID, New: created, State: state})
|
writeJSON(w, http.StatusOK, APIResponse{OK: true, UserID: userID, New: created, State: state})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAPIAction is POST /api/act/{action}: runs an action (runAction) and
|
// 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).
|
// 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) {
|
func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
|
||||||
get, err := apiParams(r)
|
get, err := apiParams(r)
|
||||||
@@ -788,42 +706,14 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
apiError(w, http.StatusBadRequest, "Bad request")
|
apiError(w, http.StatusBadRequest, "Bad request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := r.Context()
|
userID, created := s.apiUser(w, r)
|
||||||
action := r.PathValue("action")
|
if userID == "" {
|
||||||
var userID string
|
|
||||||
var created bool
|
|
||||||
var res actionResult
|
|
||||||
var saveCode *APISaveCode
|
|
||||||
switch action {
|
|
||||||
case "restore":
|
|
||||||
// Switches to the save code's player (savecode.go); no current player needed.
|
|
||||||
if userID = s.apiRestore(w, r, get("code")); userID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res = actionResult{Action: action, Message: "Welcome back"}
|
|
||||||
case "save-code":
|
|
||||||
if userID, created = s.apiUser(w, r); userID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
code, made, err := makeSaveCode(ctx, s.db, userID)
|
|
||||||
if err != nil {
|
|
||||||
apiServerError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
saveCode = &APISaveCode{Code: code, MadeAt: made.UnixMilli()}
|
|
||||||
res = actionResult{Action: action, Message: "Save code made", View: viewFromForm(get)}
|
|
||||||
default:
|
|
||||||
if userID, created = s.apiUser(w, r); userID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res, err = s.runAction(ctx, userID, action, get)
|
|
||||||
}
|
|
||||||
if errors.Is(err, errUnknownAction) {
|
|
||||||
apiError(w, http.StatusNotFound, "Unknown action")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errors.Is(err, errForbidden) {
|
ctx := r.Context()
|
||||||
apiError(w, http.StatusForbidden, "Missing permission")
|
res, err := s.runAction(ctx, userID, r.PathValue("action"), get)
|
||||||
|
if errors.Is(err, errUnknownAction) {
|
||||||
|
apiError(w, http.StatusNotFound, "Unknown action")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -846,28 +736,19 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
created, res.View, res.Message = true, View{}, "New game started"
|
created, res.View, res.Message = true, View{}, "New game started"
|
||||||
}
|
}
|
||||||
|
|
||||||
state, v, err := loadView(ctx, s.db, userID, res.View)
|
state, err := loadState(ctx, s.db, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
apiServerError(w, err)
|
apiServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast := res.toast(state)
|
toast := res.toast(state)
|
||||||
page, err := s.apiPageFor(ctx, state, v)
|
page, err := s.apiPageFor(ctx, state, res.View.resolve(state))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
apiServerError(w, err)
|
apiServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp := APIResponse{OK: !toast.Error, UserID: userID, New: created, ClientSeq: apiSeq(get("clientSeq")),
|
resp := APIResponse{OK: !toast.Error, UserID: userID, New: created, ClientSeq: apiSeq(get("clientSeq")),
|
||||||
Toast: &APIToast{Message: toast.Message, Error: toast.Error, Kind: toast.Kind}, SaveCode: saveCode}
|
Toast: &APIToast{Message: toast.Message, Error: toast.Error, Kind: toast.Kind}}
|
||||||
if !toast.Error && res.Action == "sell" {
|
|
||||||
// After the first sale, once: where the save code is (savecode.go).
|
|
||||||
if nudge, err := saveCodeNudge(ctx, s.db, userID); err != nil {
|
|
||||||
apiServerError(w, err)
|
|
||||||
return
|
|
||||||
} else if nudge {
|
|
||||||
resp.Notice = &APINotice{Kind: "save-code", Text: saveCodeNudgeText}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !toast.Error {
|
if !toast.Error {
|
||||||
if res.BoughtTool != "" {
|
if res.BoughtTool != "" {
|
||||||
resp.BuyModal = apiBuyModal(state.buySuggestions(res.BoughtTool))
|
resp.BuyModal = apiBuyModal(state.buySuggestions(res.BoughtTool))
|
||||||
@@ -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"]}, "…"]}
|
||||||
|
```
|
||||||
@@ -9,31 +9,33 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Image URLs, as the templates build them.
|
// Image URLs, as the templates build them.
|
||||||
const (
|
const (
|
||||||
itemImages = "/static/assets/items/"
|
itemImages = "/static/assets/items/"
|
||||||
advisorImages = "/static/assets/advisors/"
|
attachmentImages = "/static/assets/attachments/"
|
||||||
|
advisorImages = "/static/assets/advisors/"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ------------------------------------------------------------------ always present
|
// ------------------------------------------------------------------ always present
|
||||||
|
|
||||||
// APICatalog is the static game data: every tool (with its upgrade tracks), advisor,
|
// APICatalog is the static game data: every tool, attachment, advisor, resource and
|
||||||
// resource and refined item with its image and color.
|
// refined item with its image and color.
|
||||||
type APICatalog struct {
|
type APICatalog struct {
|
||||||
Tools []APICatalogTool `json:"tools"`
|
Tools []APICatalogTool `json:"tools"`
|
||||||
Advisors []APICatalogAdvisor `json:"advisors"`
|
Attachments []APICatalogAttachment `json:"attachments"`
|
||||||
Resources []APICatalogResource `json:"resources"`
|
Advisors []APICatalogAdvisor `json:"advisors"`
|
||||||
Refined []APICatalogRefined `json:"refined"`
|
Resources []APICatalogResource `json:"resources"`
|
||||||
Reach []APIReachText `json:"reach"` // the web's wording of a reach, 1 up to the whole moon
|
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
|
// 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",
|
// ("Mines 3 wells", "Strips the whole moon"; "" for one), short on deploy cards ("3 wells",
|
||||||
// "whole moon"; "" for one), wells ("1 well", "whole moon"). A reach at or
|
// "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.
|
// above the last entry's is the whole moon.
|
||||||
type APIReachText struct {
|
type APIReachText struct {
|
||||||
Reach int `json:"reach"`
|
Reach int `json:"reach"`
|
||||||
@@ -43,31 +45,25 @@ type APIReachText struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type APICatalogTool struct {
|
type APICatalogTool struct {
|
||||||
StoreItemID int `json:"storeItemId"`
|
StoreItemID int `json:"storeItemId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Code string `json:"code"` // e.g. "PD-1"
|
Code string `json:"code"` // e.g. "PD-1"
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
Yield float64 `json:"yield"` // base (level 0)
|
Yield float64 `json:"yield"` // base, tier 0
|
||||||
CycleSecs float64 `json:"cycleSecs"` // base
|
CycleSecs int `json:"cycleSecs"` // base
|
||||||
Reach int `json:"reach"`
|
Reach int `json:"reach"` // tier 0
|
||||||
Tracks []APICatalogTrack `json:"tracks"` // yield, then cycle
|
Tiers []APITierUp `json:"tiers"` // upgrades, tier 1 on
|
||||||
}
|
}
|
||||||
|
|
||||||
// APICatalogTrack is one of a tool's upgrade tracks: its levels from 1, each setting the
|
type APITierUp struct {
|
||||||
// track's value (yield per cycle, or cycle seconds) before bonuses.
|
Tier int `json:"tier"`
|
||||||
type APICatalogTrack struct {
|
Price float64 `json:"price"`
|
||||||
Kind string `json:"kind"` // yield, cycle (upgrade with track=<kind>)
|
ClaimBoost float64 `json:"claimBoost"`
|
||||||
Name string `json:"name"`
|
Reach int `json:"reach"` // wells per cycle from this tier on
|
||||||
Levels []APITrackLevel `json:"levels"`
|
Parts []APIAmount `json:"parts"` // refined items it uses up
|
||||||
}
|
Image string `json:"image"` // the tool's image at this tier
|
||||||
|
|
||||||
type APITrackLevel struct {
|
|
||||||
Level int `json:"level"`
|
|
||||||
Value float64 `json:"value"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
Parts []APIAmount `json:"parts"` // refined items it uses up
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIEffect struct {
|
type APIEffect struct {
|
||||||
@@ -77,6 +73,16 @@ type APIEffect struct {
|
|||||||
Refine float64 `json:"refine,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 {
|
type APICatalogAdvisor struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -133,23 +139,26 @@ func apiNeed(need map[string]float64) []APIAmount {
|
|||||||
|
|
||||||
func apiCatalog(s *State) *APICatalog {
|
func apiCatalog(s *State) *APICatalog {
|
||||||
c := cfg()
|
c := cfg()
|
||||||
cat := &APICatalog{Tools: []APICatalogTool{}, Advisors: []APICatalogAdvisor{},
|
cat := &APICatalog{Tools: []APICatalogTool{}, Attachments: []APICatalogAttachment{}, Advisors: []APICatalogAdvisor{},
|
||||||
Resources: []APICatalogResource{}, Refined: []APICatalogRefined{}, Reach: []APIReachText{}}
|
Resources: []APICatalogResource{}, Refined: []APICatalogRefined{}, Reach: []APIReachText{}}
|
||||||
for i := range s.Config.StoreItems {
|
for i := range s.Config.StoreItems {
|
||||||
item := &s.Config.StoreItems[i]
|
item := &s.Config.StoreItems[i]
|
||||||
t := APICatalogTool{StoreItemID: item.ID, Name: item.Name, Code: toolCode(item.Name), Image: itemImages + item.Image,
|
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,
|
Description: item.Description, Price: item.Price, Yield: item.ClaimAmount, CycleSecs: item.CompletionTimeInSecs,
|
||||||
CycleSecs: float64(item.CompletionTimeInSecs), Reach: item.ToolReach(), Tracks: []APICatalogTrack{}}
|
Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}}
|
||||||
for _, kind := range trackKinds {
|
for _, u := range item.Upgrades {
|
||||||
tr := item.Track(kind)
|
t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost,
|
||||||
ct := APICatalogTrack{Kind: kind, Name: tr.Name, Levels: []APITrackLevel{}}
|
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier)),
|
||||||
for n, l := range tr.Levels {
|
Image: itemImages + item.ImageAt(u.Tier)})
|
||||||
ct.Levels = append(ct.Levels, APITrackLevel{Level: n + 1, Value: l.Value, Price: l.Price, Parts: apiNeed(l.Parts)})
|
|
||||||
}
|
|
||||||
t.Tracks = append(t.Tracks, ct)
|
|
||||||
}
|
}
|
||||||
cat.Tools = append(cat.Tools, t)
|
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 {
|
for i := range c.Advisors.Items {
|
||||||
a := &c.Advisors.Items[i]
|
a := &c.Advisors.Items[i]
|
||||||
cat.Advisors = append(cat.Advisors, APICatalogAdvisor{Name: a.Name, Title: a.Title, Image: advisorImages + a.Image,
|
cat.Advisors = append(cat.Advisors, APICatalogAdvisor{Name: a.Name, Title: a.Title, Image: advisorImages + a.Image,
|
||||||
@@ -196,8 +205,9 @@ type APIConfirm struct {
|
|||||||
|
|
||||||
// APIRules are the numbers a client needs to predict results (api.md, Prediction).
|
// APIRules are the numbers a client needs to predict results (api.md, Prediction).
|
||||||
type APIRules struct {
|
type APIRules struct {
|
||||||
WellSupplyMax float64 `json:"wellSupplyMax"` // the default grade's biggest well (each moon has its maxSupply)
|
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: the selected moon's grade
|
MaxSupply float64 `json:"maxSupply"` // the scale of the web's supply bars
|
||||||
|
SlotsPerTool int `json:"slotsPerTool"`
|
||||||
WellsMin int `json:"wellsMin"`
|
WellsMin int `json:"wellsMin"`
|
||||||
WellsMax int `json:"wellsMax"`
|
WellsMax int `json:"wellsMax"`
|
||||||
Pressure APIPressure `json:"pressure"`
|
Pressure APIPressure `json:"pressure"`
|
||||||
@@ -205,7 +215,7 @@ type APIRules struct {
|
|||||||
MoonResale MoonResale `json:"moonResale"`
|
MoonResale MoonResale `json:"moonResale"`
|
||||||
EquipmentResale float64 `json:"equipmentResale"` // factor of the credit price
|
EquipmentResale float64 `json:"equipmentResale"` // factor of the credit price
|
||||||
BonusCaps BonusCaps `json:"bonusCaps"`
|
BonusCaps BonusCaps `json:"bonusCaps"`
|
||||||
Bonus APIEffect `json:"bonus"` // the advisors' bonus now, capped
|
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"
|
Boosts map[string]APIMap `json:"boosts"` // live event multipliers: price, cycle, yield, by resource or "all"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,20 +229,20 @@ type APIPressure struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type APIRefineryRules struct {
|
type APIRefineryRules struct {
|
||||||
BulkLog float64 `json:"bulkLog"`
|
BulkExponent float64 `json:"bulkExponent"`
|
||||||
MaxBatch int `json:"maxBatch"`
|
MaxBatch int `json:"maxBatch"`
|
||||||
Speed float64 `json:"speed"` // advisors' refine bonus × the cycle event for "all"
|
Speed float64 `json:"speed"` // advisors' refine bonus × the cycle event for "all"
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiRules(p *Page) *APIRules {
|
func apiRules(p *Page) *APIRules {
|
||||||
s, c := p.S, cfg()
|
s, c := p.S, cfg()
|
||||||
b := s.Bonus()
|
b := s.Bonus()
|
||||||
lo, hi := s.wellCountRange()
|
lo, hi := wellCountRange()
|
||||||
r := &APIRules{WellSupplyMax: s.MoonGrade().WellSupplyMax, MaxSupply: p.MaxSupply,
|
r := &APIRules{WellSupplyMax: c.WellSupplyMax, MaxSupply: p.MaxSupply, SlotsPerTool: c.Attachments.SlotsPerTool,
|
||||||
WellsMin: lo, WellsMax: hi,
|
WellsMin: lo, WellsMax: hi,
|
||||||
Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale,
|
Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale,
|
||||||
HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale},
|
HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale},
|
||||||
Refinery: APIRefineryRules{BulkLog: c.Refinery.BulkLog, MaxBatch: c.Refinery.maxBatchAt(s.RefineryBatchLevel),
|
Refinery: APIRefineryRules{BulkExponent: c.Refinery.BulkExponent, MaxBatch: c.Refinery.MaxBatch,
|
||||||
Speed: b.Refine * s.CycleBoosts.For("all")},
|
Speed: b.Refine * s.CycleBoosts.For("all")},
|
||||||
MoonResale: c.MoonResale, EquipmentResale: c.EquipmentResale.Factor, BonusCaps: c.BonusCaps,
|
MoonResale: c.MoonResale, EquipmentResale: c.EquipmentResale.Factor, BonusCaps: c.BonusCaps,
|
||||||
Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield, Sell: b.Sell, Refine: b.Refine},
|
Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield, Sell: b.Sell, Refine: b.Refine},
|
||||||
@@ -304,51 +314,47 @@ type APILiveEvent struct {
|
|||||||
// ------------------------------------------------------------------ equipment
|
// ------------------------------------------------------------------ equipment
|
||||||
|
|
||||||
type APIEquipment struct {
|
type APIEquipment struct {
|
||||||
Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped
|
Tools []APIToolRow `json:"tools"` // copies of a tool (same type, tier, attachments) grouped
|
||||||
// The upgrade ladders by ladderKey (store item and levels: every tool with those has
|
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
|
||||||
// the same), each track's by kind; a tool row names its own (ladderKey).
|
|
||||||
Ladders map[string]map[string][]APILadderStep `json:"ladders"`
|
|
||||||
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIToolRow struct {
|
type APIToolRow struct {
|
||||||
ID string `json:"id"` // the copy shown (selected, else an idle one, else the first to finish)
|
ID string `json:"id"` // the copy shown (selected, else an idle one, else the first to finish)
|
||||||
StoreItemID int `json:"storeItemId"`
|
StoreItemID int `json:"storeItemId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
YieldLevel int `json:"yieldLevel"`
|
Tier int `json:"tier"`
|
||||||
CycleLevel int `json:"cycleLevel"`
|
MaxTier int `json:"maxTier"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Idle int `json:"idle"`
|
Idle int `json:"idle"`
|
||||||
Reach int `json:"reach"`
|
Reach int `json:"reach"`
|
||||||
CycleSecs float64 `json:"cycleSecs"`
|
CycleSecs int `json:"cycleSecs"`
|
||||||
Yield float64 `json:"yield"`
|
Yield float64 `json:"yield"`
|
||||||
Selected bool `json:"selected"`
|
Attachments []string `json:"attachments"`
|
||||||
Stake *APIStake `json:"stake"` // the shown copy's drill
|
Slots int `json:"slots"`
|
||||||
Tracks []APITrack `json:"tracks"` // yield, then cycle
|
FreeSlots int `json:"freeSlots"`
|
||||||
LadderKey string `json:"ladderKey"` // its tracks' ladders in equipment.ladders
|
Selected bool `json:"selected"`
|
||||||
Resale float64 `json:"resale"`
|
Stake *APIStake `json:"stake"` // the shown copy's drill
|
||||||
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
|
Next *APINextTier `json:"next"` // null at the max tier
|
||||||
NoSaleText string `json:"noSaleText"`
|
Preview *APIPreview `json:"preview"`
|
||||||
Bonus APIEffect `json:"bonus"` // advisors, capped
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// APITrack is one of a tool's upgrade tracks: values are a stake's now (yield per cycle,
|
type APIGearDetail struct {
|
||||||
// or the cycle in seconds), with bonuses and live events.
|
Name string `json:"name"`
|
||||||
type APITrack struct {
|
Image string `json:"image"`
|
||||||
Kind string `json:"kind"` // yield, cycle: upgrade with tool=<id>&track=<kind>
|
Effect APIEffect `json:"effect"`
|
||||||
Name string `json:"name"`
|
EffectText string `json:"effectText"`
|
||||||
Level int `json:"level"`
|
|
||||||
Max int `json:"max"`
|
|
||||||
Value float64 `json:"value"`
|
|
||||||
Next *APINextLevel `json:"next"` // null at the top level
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type APINextLevel struct {
|
type APINextTier struct {
|
||||||
Level int `json:"level"`
|
Tier int `json:"tier"`
|
||||||
Value float64 `json:"value"`
|
|
||||||
Label string `json:"label"` // the button's track name and level, "Twin rotors 2"
|
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
Parts []APIPart `json:"parts"`
|
Parts []APIPart `json:"parts"`
|
||||||
CanAfford bool `json:"canAfford"` // credits and parts
|
CanAfford bool `json:"canAfford"` // credits and parts
|
||||||
@@ -356,12 +362,8 @@ type APINextLevel struct {
|
|||||||
Short float64 `json:"short"` // credits missing
|
Short float64 `json:"short"` // credits missing
|
||||||
// The web's texts: why the upgrade button is disabled (its tooltip) and the line under it
|
// The web's texts: why the upgrade button is disabled (its tooltip) and the line under it
|
||||||
// when credits are short ("Need ₥X more").
|
// when credits are short ("Need ₥X more").
|
||||||
BlockedText string `json:"blockedText"`
|
BlockedText string `json:"blockedText"`
|
||||||
ShortText string `json:"shortText"`
|
ShortText string `json:"shortText"`
|
||||||
RateFrom float64 `json:"rateFrom"` // credits per hour now and after it
|
|
||||||
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 APIPart struct {
|
type APIPart struct {
|
||||||
@@ -370,106 +372,148 @@ type APIPart struct {
|
|||||||
Have float64 `json:"have"`
|
Have float64 `json:"have"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APILadderStep struct {
|
type APIPreview struct {
|
||||||
Level int `json:"level"`
|
Tier int `json:"tier"`
|
||||||
Value float64 `json:"value"`
|
YieldFrom float64 `json:"yieldFrom"`
|
||||||
Rate float64 `json:"rate"` // credits per hour
|
YieldTo float64 `json:"yieldTo"`
|
||||||
Price float64 `json:"price"` // 0 for level 0
|
SecsFrom int `json:"secsFrom"`
|
||||||
Parts []APIPart `json:"parts"`
|
SecsTo int `json:"secsTo"`
|
||||||
Current bool `json:"current"`
|
ReachFrom int `json:"reachFrom"`
|
||||||
Next bool `json:"next"`
|
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 {
|
type APIDrillPanel struct {
|
||||||
ToolID string `json:"toolId"`
|
ToolID string `json:"toolId"`
|
||||||
Mining bool `json:"mining"`
|
Slots []APISlot `json:"slots"`
|
||||||
Resale float64 `json:"resale"`
|
Spares []APISpare `json:"spares"`
|
||||||
NoSale string `json:"noSale"`
|
Mining bool `json:"mining"`
|
||||||
NoSaleText string `json:"noSaleText"`
|
Resale float64 `json:"resale"`
|
||||||
Confirm APIConfirm `json:"confirm"` // before sell-tool
|
NoSale string `json:"noSale"`
|
||||||
IdleSame int `json:"idleSame"` // while it mines: idle tools of its type
|
NoSaleText string `json:"noSaleText"`
|
||||||
IdleBest string `json:"idleBest"` // the one pickTool would deploy
|
Confirm APIConfirm `json:"confirm"` // before sell-tool
|
||||||
DeployTo *APISuggestion `json:"deployTo"` // its best well
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiParts(rows []PartRow) []APIPart {
|
type APISlot struct {
|
||||||
out := []APIPart{}
|
N int `json:"n"`
|
||||||
for _, p := range rows {
|
Attachment string `json:"attachment"` // "" empty; unequip with item=<name>&to=<toolId>
|
||||||
out = append(out, APIPart{Name: p.Name, Need: p.Need, Have: p.Have})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiTracks(s *State, rows []TrackRow) []APITrack {
|
type APISpare struct {
|
||||||
out := []APITrack{}
|
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 {
|
for _, r := range rows {
|
||||||
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value}
|
out = append(out, APITierRow{Tier: r.Tier, CycleSecs: r.Secs, Yield: r.Yield, Reach: r.Reach, Rate: r.Rate,
|
||||||
if n := r.Next; n != nil {
|
Current: r.Current, Next: r.Next})
|
||||||
next := &APINextLevel{Level: r.Level + 1, Value: r.NextValue, Label: r.Label(), Price: n.Price,
|
|
||||||
Parts: apiParts(r.Parts), CanAfford: r.CanAfford, Short: max(0, n.Price-s.Balance),
|
|
||||||
BlockedText: r.Missing, ShortText: r.Short, RateFrom: r.RateFrom, RateTo: r.RateTo, Cost: r.Cost}
|
|
||||||
for _, part := range r.Parts {
|
|
||||||
if !part.OK() && next.Blocked == "" {
|
|
||||||
next.Blocked = "parts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if next.Short > 0 {
|
|
||||||
next.Blocked = "credits"
|
|
||||||
}
|
|
||||||
if gain := r.RateGain(); gain > 0 {
|
|
||||||
secs := r.Cost / gain * 3600
|
|
||||||
next.PaybackSecs = &secs
|
|
||||||
}
|
|
||||||
t.Next = next
|
|
||||||
}
|
|
||||||
out = append(out, t)
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ladderKey names a tool's ladders: "<store item>:<yield level>:<cycle level>".
|
func apiGearDetail(gear []*Attachment) []APIGearDetail {
|
||||||
func ladderKey(storeItemID int, lv Levels) string {
|
out := []APIGearDetail{}
|
||||||
return fmt.Sprintf("%d:%d:%d", storeItemID, lv.Yield, lv.Cycle)
|
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()})
|
||||||
// apiLadders are the item's ladders at the levels, by kind (the tracks it has).
|
|
||||||
func apiLadders(s *State, item *StoreItem, lv Levels, tracks []TrackRow) map[string][]APILadderStep {
|
|
||||||
out := map[string][]APILadderStep{}
|
|
||||||
for _, r := range tracks {
|
|
||||||
steps := []APILadderStep{}
|
|
||||||
for _, st := range s.trackLadder(item, lv, r.Kind) {
|
|
||||||
steps = append(steps, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
|
|
||||||
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
|
|
||||||
}
|
|
||||||
out[r.Kind] = steps
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiEquipment(p *Page) *APIEquipment {
|
func apiEquipment(p *Page) *APIEquipment {
|
||||||
s := p.S
|
s := p.S
|
||||||
b := s.Bonus()
|
e := &APIEquipment{Tools: []APIToolRow{}}
|
||||||
e := &APIEquipment{Tools: []APIToolRow{}, Ladders: map[string]map[string][]APILadderStep{}}
|
|
||||||
for _, row := range p.Equipment {
|
for _, row := range p.Equipment {
|
||||||
key := ladderKey(row.StoreItemID, row.Levels())
|
b := s.toolBonus(row.ID)
|
||||||
if _, ok := e.Ladders[key]; !ok {
|
|
||||||
e.Ladders[key] = apiLadders(s, row.Item, row.Levels(), row.Tracks)
|
|
||||||
}
|
|
||||||
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
|
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
|
||||||
Image: itemImages + row.Item.Image, YieldLevel: row.YieldLevel, CycleLevel: row.CycleLevel,
|
Image: itemImages + row.Item.ImageAt(row.Tier), Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle,
|
||||||
Count: row.Count, Idle: row.Idle, Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield,
|
Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield, Attachments: apiGear(row.Gear),
|
||||||
Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), LadderKey: key, Resale: row.Item.ToolResale(row.Levels()),
|
Slots: len(row.Gear) + row.FreeSlots, FreeSlots: row.FreeSlots, Selected: row.Selected,
|
||||||
NoSale: s.noSale(row), NoSaleText: noSaleText[s.noSale(row)], Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}}
|
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 {
|
if row.Stake != nil {
|
||||||
st := apiStake(*row.Stake, false)
|
st := apiStake(*row.Stake, false)
|
||||||
t.Stake = &st
|
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)
|
e.Tools = append(e.Tools, t)
|
||||||
}
|
}
|
||||||
if d := p.Drill; d != nil {
|
if d := p.Drill; d != nil {
|
||||||
dp := &APIDrillPanel{ToolID: d.ID, Mining: d.Mining, Resale: d.Resale,
|
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,
|
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}}
|
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 {
|
if d.IdleBest != nil {
|
||||||
dp.IdleBest = d.IdleBest.ID
|
dp.IdleBest = d.IdleBest.ID
|
||||||
}
|
}
|
||||||
@@ -488,7 +532,7 @@ func apiEquipment(p *Page) *APIEquipment {
|
|||||||
type APIBuyModal struct {
|
type APIBuyModal struct {
|
||||||
ToolID string `json:"toolId"`
|
ToolID string `json:"toolId"`
|
||||||
StoreItemID int `json:"storeItemId"`
|
StoreItemID int `json:"storeItemId"`
|
||||||
CycleSecs float64 `json:"cycleSecs"`
|
CycleSecs int `json:"cycleSecs"`
|
||||||
Yield float64 `json:"yield"`
|
Yield float64 `json:"yield"`
|
||||||
Reach int `json:"reach"`
|
Reach int `json:"reach"`
|
||||||
NoMoons bool `json:"noMoons"`
|
NoMoons bool `json:"noMoons"`
|
||||||
@@ -532,7 +576,8 @@ func apiBuyModal(m *BuyModal) *APIBuyModal {
|
|||||||
// ------------------------------------------------------------------ store
|
// ------------------------------------------------------------------ store
|
||||||
|
|
||||||
type APIStore struct {
|
type APIStore struct {
|
||||||
Tools []APIStoreTool `json:"tools"`
|
Tools []APIStoreTool `json:"tools"`
|
||||||
|
Attachments []APIStoreGear `json:"attachments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIStoreTool struct {
|
type APIStoreTool struct {
|
||||||
@@ -545,13 +590,27 @@ type APIStoreTool struct {
|
|||||||
CanAfford bool `json:"canAfford"`
|
CanAfford bool `json:"canAfford"`
|
||||||
Owned int `json:"owned"`
|
Owned int `json:"owned"`
|
||||||
Yield float64 `json:"yield"` // base, as the card shows
|
Yield float64 `json:"yield"` // base, as the card shows
|
||||||
CycleSecs float64 `json:"cycleSecs"` // base
|
CycleSecs int `json:"cycleSecs"` // base
|
||||||
Reach int `json:"reach"`
|
Reach int `json:"reach"`
|
||||||
Rate float64 `json:"rate"` // credits per hour it would earn, with advisors
|
Rate float64 `json:"rate"` // credits per hour it would earn, with advisors
|
||||||
BestRate float64 `json:"bestRate"` // the same for the player's best tool
|
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
|
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.
|
// 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 {
|
func whyShort(canAfford bool, balance, price float64) string {
|
||||||
if canAfford {
|
if canAfford {
|
||||||
@@ -562,12 +621,17 @@ func whyShort(canAfford bool, balance, price float64) string {
|
|||||||
|
|
||||||
func apiStore(p *Page) *APIStore {
|
func apiStore(p *Page) *APIStore {
|
||||||
bal := p.S.Balance
|
bal := p.S.Balance
|
||||||
st := &APIStore{Tools: []APIStoreTool{}}
|
st := &APIStore{Tools: []APIStoreTool{}, Attachments: []APIStoreGear{}}
|
||||||
for _, r := range p.Store {
|
for _, r := range p.Store {
|
||||||
st.Tools = append(st.Tools, APIStoreTool{StoreItemID: r.Item.ID, Name: r.Item.Name, Code: toolCode(r.Item.Name),
|
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,
|
Image: itemImages + r.Item.Image, Description: r.Item.Description, Price: r.Item.Price, CanAfford: r.CanAfford,
|
||||||
Owned: r.Owned, Yield: r.Item.ClaimAmount, CycleSecs: float64(r.Item.CompletionTimeInSecs),
|
Owned: r.Owned, Yield: r.Item.ClaimAmount, CycleSecs: r.Item.CompletionTimeInSecs,
|
||||||
Reach: r.Item.ToolReach(), Rate: r.Rate, BestRate: r.BestRate, WhyText: whyShort(r.CanAfford, bal, r.Item.Price)})
|
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
|
return st
|
||||||
}
|
}
|
||||||
@@ -575,23 +639,19 @@ func apiStore(p *Page) *APIStore {
|
|||||||
// ------------------------------------------------------------------ refinery
|
// ------------------------------------------------------------------ refinery
|
||||||
|
|
||||||
type APIRefinery struct {
|
type APIRefinery struct {
|
||||||
Slots []APIRefinerySlot `json:"slots"`
|
Slots []APIRefinerySlot `json:"slots"`
|
||||||
SlotCount int `json:"slotCount"`
|
SlotCount int `json:"slotCount"`
|
||||||
MaxSlots int `json:"maxSlots"`
|
MaxSlots int `json:"maxSlots"`
|
||||||
NextSlot int `json:"nextSlot"`
|
NextSlot int `json:"nextSlot"`
|
||||||
NextSlotPrice float64 `json:"nextSlotPrice"` // 0 at the cap
|
NextSlotPrice float64 `json:"nextSlotPrice"` // 0 at the cap
|
||||||
CanBuySlot bool `json:"canBuySlot"`
|
CanBuySlot bool `json:"canBuySlot"`
|
||||||
Free int `json:"free"`
|
Free int `json:"free"`
|
||||||
Ready int `json:"ready"`
|
Ready int `json:"ready"`
|
||||||
MaxBatch int `json:"maxBatch"` // most per batch now (batch upgrades bought)
|
MaxBatch int `json:"maxBatch"`
|
||||||
NextBatch int `json:"nextBatch"` // after upgrade-batch; 0 at the top
|
Recipes []APIRecipe `json:"recipes"`
|
||||||
NextBatchPrice float64 `json:"nextBatchPrice"` // 0 at the top
|
Stock []APIAmount `json:"stock"` // refined items owned
|
||||||
CanUpgradeBatch bool `json:"canUpgradeBatch"`
|
BuySlot *APIConfirm `json:"buySlot"` // before buy-slot; null at the cap
|
||||||
UpgradeBatchWhy string `json:"upgradeBatchWhyText"` // why the upgrade is disabled, "" if it isn't
|
BuySlotWhy string `json:"buySlotWhyText"` // why "+ Slot" is disabled, "" if it isn't
|
||||||
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 {
|
type APIRefinerySlot struct {
|
||||||
@@ -623,11 +683,7 @@ func apiRefinery(p *Page) *APIRefinery {
|
|||||||
speed := s.Bonus().Refine * s.CycleBoosts.For("all")
|
speed := s.Bonus().Refine * s.CycleBoosts.For("all")
|
||||||
r := &APIRefinery{Slots: []APIRefinerySlot{}, SlotCount: rp.SlotCount, MaxSlots: rp.MaxSlots, NextSlot: rp.NextSlot,
|
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,
|
NextSlotPrice: rp.NextSlotPrice, CanBuySlot: rp.CanBuySlot, Free: rp.Free, Ready: rp.Ready, MaxBatch: rp.MaxBatch,
|
||||||
NextBatch: rp.NextBatch, NextBatchPrice: rp.NextBatchPrice, CanUpgradeBatch: rp.CanUpgradeBatch,
|
|
||||||
Recipes: []APIRecipe{}, Stock: []APIAmount{}}
|
Recipes: []APIRecipe{}, Stock: []APIAmount{}}
|
||||||
if rp.NextBatch > 0 {
|
|
||||||
r.UpgradeBatchWhy = whyShort(rp.CanUpgradeBatch, s.Balance, rp.NextBatchPrice)
|
|
||||||
}
|
|
||||||
if rp.NextSlotPrice > 0 {
|
if rp.NextSlotPrice > 0 {
|
||||||
r.BuySlot = &APIConfirm{Title: "New refinery slot", Text: rp.SlotConfirm(), OK: "Buy slot"}
|
r.BuySlot = &APIConfirm{Title: "New refinery slot", Text: rp.SlotConfirm(), OK: "Buy slot"}
|
||||||
r.BuySlotWhy = whyShort(rp.CanBuySlot, s.Balance, rp.NextSlotPrice)
|
r.BuySlotWhy = whyShort(rp.CanBuySlot, s.Balance, rp.NextSlotPrice)
|
||||||
@@ -672,27 +728,16 @@ func apiRefinery(p *Page) *APIRefinery {
|
|||||||
|
|
||||||
type APIAdvisors struct {
|
type APIAdvisors struct {
|
||||||
Seats int `json:"seats"`
|
Seats int `json:"seats"`
|
||||||
Hired int `json:"hired"` // seats filled
|
Hired int `json:"hired"`
|
||||||
Benched int `json:"benched"` // hired, not seated
|
|
||||||
MaxSeats int `json:"maxSeats"`
|
MaxSeats int `json:"maxSeats"`
|
||||||
NextSeat int `json:"nextSeat"`
|
NextSeat int `json:"nextSeat"`
|
||||||
NextSeatPrice float64 `json:"nextSeatPrice"` // 0 at the cap
|
NextSeatPrice float64 `json:"nextSeatPrice"` // 0 at the cap
|
||||||
CanBuySeat bool `json:"canBuySeat"`
|
CanBuySeat bool `json:"canBuySeat"`
|
||||||
SeatFree bool `json:"seatFree"`
|
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"`
|
Bonuses []APIBonusRow `json:"bonuses"`
|
||||||
Advisors []APIAdvisorUp `json:"advisors"`
|
Advisors []APIAdvisorUp `json:"advisors"`
|
||||||
BuySeat *APIConfirm `json:"buySeat"` // before buy-seat; null at the cap
|
BuySeat *APIConfirm `json:"buySeat"` // before buy-seat; null at the cap
|
||||||
BuySeatWhy string `json:"buySeatWhyText"` // why "+ Seat" is disabled, "" if it isn't
|
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 {
|
type APIAdvisorUp struct {
|
||||||
@@ -703,48 +748,26 @@ type APIAdvisorUp struct {
|
|||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
Effect APIEffect `json:"effect"`
|
Effect APIEffect `json:"effect"`
|
||||||
Hired bool `json:"hired"`
|
Hired bool `json:"hired"`
|
||||||
Seat int `json:"seat"` // hired: their seat, 0 if not seated (no bonus)
|
|
||||||
CanAfford bool `json:"canAfford"`
|
CanAfford bool `json:"canAfford"`
|
||||||
CanHire bool `json:"canHire"` // not hired, a seat free, affordable
|
CanHire bool `json:"canHire"` // not hired, a seat free, affordable
|
||||||
EffectText string `json:"effectText"`
|
EffectText string `json:"effectText"`
|
||||||
Why string `json:"why"` // hired, seats (none free), credits; "" can hire
|
Why string `json:"why"` // hired, seats (none free), credits; "" can hire
|
||||||
WhyText string `json:"whyText"`
|
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 {
|
func apiAdvisors(p *Page) *APIAdvisors {
|
||||||
ap := p.Advisors
|
ap := p.Advisors
|
||||||
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, Benched: ap.Benched, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
|
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
|
||||||
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(), FreeSeat: ap.FreeSeat,
|
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(),
|
||||||
SeatList: []APISeat{}, Bench: []string{}, Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}, Selected: p.V.Adv}
|
Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}}
|
||||||
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 {
|
if ap.NextSeatPrice > 0 {
|
||||||
a.BuySeat = &APIConfirm{Title: "New seat", Text: ap.SeatConfirm(), OK: "Buy seat"}
|
a.BuySeat = &APIConfirm{Title: "New seat", Text: ap.SeatConfirm(), OK: "Buy seat"}
|
||||||
a.BuySeatWhy = whyShort(ap.CanBuySeat, p.S.Balance, ap.NextSeatPrice)
|
a.BuySeatWhy = whyShort(ap.CanBuySeat, p.S.Balance, ap.NextSeatPrice)
|
||||||
}
|
}
|
||||||
up := func(c AdvisorCard) APIAdvisorUp {
|
for _, c := range ap.Cards {
|
||||||
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Description: c.Description, Price: c.Price,
|
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Image: advisorImages + c.Image,
|
||||||
Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)),
|
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(),
|
Hired: c.Hired, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(), EffectText: c.Advisor.Effect()}
|
||||||
EffectText: c.Advisor.Effect()}
|
|
||||||
if c.Image != "" {
|
|
||||||
up.Image = advisorImages + c.Image
|
|
||||||
}
|
|
||||||
switch {
|
switch {
|
||||||
case c.Hired:
|
case c.Hired:
|
||||||
up.Why, up.WhyText = "hired", "On your board"
|
up.Why, up.WhyText = "hired", "On your board"
|
||||||
@@ -753,42 +776,11 @@ func apiAdvisors(p *Page) *APIAdvisors {
|
|||||||
case !c.CanAfford:
|
case !c.CanAfford:
|
||||||
up.Why, up.WhyText = "credits", ap.HireWhy(c, p.S.Balance)
|
up.Why, up.WhyText = "credits", ap.HireWhy(c, p.S.Balance)
|
||||||
}
|
}
|
||||||
if c.Hired {
|
a.Advisors = append(a.Advisors, up)
|
||||||
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
|
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
|
// ------------------------------------------------------------------ leaderboard, help, new moon
|
||||||
|
|
||||||
type APILeaderboard struct {
|
type APILeaderboard struct {
|
||||||
@@ -844,69 +836,27 @@ func apiHelp(s *State) *APIHelp {
|
|||||||
"Pick a well on your moon and deploy a tool on it.",
|
"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.`,
|
`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.",
|
"Sell resources in the Market, upgrade your equipment, claim more moons.",
|
||||||
"Each drill has two upgrade tracks in Equipment: one raises what a cycle mines, the other makes cycles shorter. Hire advisors to boost your whole operation.",
|
"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.",
|
}, Note: "Your progress is saved on this device.",
|
||||||
NewGame: APIConfirm{Title: "Start over", Text: newGameConfirm, OK: "Start over", Danger: true}}
|
NewGame: APIConfirm{Title: "Start over", Text: newGameConfirm, OK: "Start over", Danger: true}}
|
||||||
}
|
}
|
||||||
|
|
||||||
type APINewMoon struct {
|
type APINewMoon struct {
|
||||||
Grade string `json:"grade"` // the default grade (buy-moon without a grade); the fields below are its
|
Price float64 `json:"price"`
|
||||||
Grades []APIMoonGrade `json:"grades"` // every grade, cheapest first: buy-moon with grade=<grade>
|
CanAfford bool `json:"canAfford"`
|
||||||
Price float64 `json:"price"`
|
After float64 `json:"after"` // balance after buying
|
||||||
CanAfford bool `json:"canAfford"`
|
Need float64 `json:"need"` // credits missing
|
||||||
After float64 `json:"after"` // balance after buying
|
Types []string `json:"types"`
|
||||||
Need float64 `json:"need"` // credits missing
|
Resources []string `json:"resources"`
|
||||||
Types []string `json:"types"`
|
WellsMin int `json:"wellsMin"`
|
||||||
Resources []string `json:"resources"`
|
WellsMax int `json:"wellsMax"`
|
||||||
WellsMin int `json:"wellsMin"`
|
WhyText string `json:"whyText"` // why "Claim and reveal" is disabled, "" if it isn't
|
||||||
WellsMax int `json:"wellsMax"`
|
|
||||||
WhyText string `json:"whyText"` // why "Claim and reveal" is disabled, "" if it isn't
|
|
||||||
}
|
|
||||||
|
|
||||||
// APIMoonGrade is one moon vendor in the new-moon panel.
|
|
||||||
type APIMoonGrade struct {
|
|
||||||
Grade string `json:"grade"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
CanAfford bool `json:"canAfford"`
|
|
||||||
WellsMin int `json:"wellsMin"`
|
|
||||||
WellsMax int `json:"wellsMax"`
|
|
||||||
SupplyMin float64 `json:"supplyMin"` // units per well, from supplyMin to just under supplyMax
|
|
||||||
SupplyMax float64 `json:"supplyMax"`
|
|
||||||
Default bool `json:"default"` // what buy-moon without a grade buys
|
|
||||||
WhyText string `json:"whyText"` // why its buy button is disabled, "" if it isn't
|
|
||||||
// Moon packs (once the store is open): buy-moon with grade=<grade>&count=<count>.
|
|
||||||
Packs []APIMoonPack `json:"packs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// APIMoonPack is count moons of a grade bought at once, for price (discount off; each moon
|
|
||||||
// at least a little better than a single one's floor).
|
|
||||||
type APIMoonPack struct {
|
|
||||||
Count int `json:"count"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
Discount float64 `json:"discount"`
|
|
||||||
CanAfford bool `json:"canAfford"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiMoonGrades(gs []GradeOffer) []APIMoonGrade {
|
|
||||||
out := []APIMoonGrade{}
|
|
||||||
for _, g := range gs {
|
|
||||||
a := APIMoonGrade{Grade: g.Grade, Price: g.Price, CanAfford: g.CanAfford, WellsMin: g.WellsMin, WellsMax: g.WellsMax,
|
|
||||||
SupplyMin: g.SupplyMin, SupplyMax: g.SupplyMax, Default: g.Default, Packs: []APIMoonPack{}}
|
|
||||||
for _, p := range g.Packs {
|
|
||||||
a.Packs = append(a.Packs, APIMoonPack{Count: p.Count, Price: p.Price, Discount: p.Discount, CanAfford: p.CanAfford})
|
|
||||||
}
|
|
||||||
if !g.CanAfford {
|
|
||||||
a.WhyText = "Not enough credits"
|
|
||||||
}
|
|
||||||
out = append(out, a)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiNewMoon(t *NewMoonTeaser) *APINewMoon {
|
func apiNewMoon(t *NewMoonTeaser) *APINewMoon {
|
||||||
lo, hi := cfg().grade(t.Grade).wellCountRange()
|
lo, hi := wellCountRange()
|
||||||
a := &APINewMoon{Grade: t.Grade, Grades: apiMoonGrades(t.Grades), Price: t.Price, CanAfford: t.CanAfford, After: t.After,
|
a := &APINewMoon{Price: t.Price, CanAfford: t.CanAfford, After: t.After, Need: t.Need, Types: []string{},
|
||||||
Need: t.Need, Types: []string{}, Resources: []string{}, WellsMin: lo, WellsMax: hi}
|
Resources: []string{}, WellsMin: lo, WellsMax: hi}
|
||||||
if !t.CanAfford {
|
if !t.CanAfford {
|
||||||
a.WhyText = "Not enough credits"
|
a.WhyText = "Not enough credits"
|
||||||
}
|
}
|
||||||
@@ -920,9 +870,8 @@ type APIReveal struct {
|
|||||||
MoonID string `json:"moonId"`
|
MoonID string `json:"moonId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Grade string `json:"grade"`
|
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Rating APIRating `json:"rating"` // against moons of its grade
|
Rating APIRating `json:"rating"`
|
||||||
Headline string `json:"headline"`
|
Headline string `json:"headline"`
|
||||||
Sub string `json:"sub"`
|
Sub string `json:"sub"`
|
||||||
Worth float64 `json:"worth"` // its wells at market prices now
|
Worth float64 `json:"worth"` // its wells at market prices now
|
||||||
@@ -931,16 +880,6 @@ type APIReveal struct {
|
|||||||
MaxSupply float64 `json:"maxSupply"` // a full bar
|
MaxSupply float64 `json:"maxSupply"` // a full bar
|
||||||
FullAt float64 `json:"fullAt"` // wells from here count as full
|
FullAt float64 `json:"fullAt"` // wells from here count as full
|
||||||
Wells []APIRevealWell `json:"wells"`
|
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 {
|
|
||||||
Grade string `json:"grade"` // the grade just bought: buy-moon with grade=<grade>
|
|
||||||
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 {
|
type APIRevealWell struct {
|
||||||
@@ -954,11 +893,10 @@ func apiReveal(r *MoonReveal) *APIReveal {
|
|||||||
if r == nil {
|
if r == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
a := &APIReveal{MoonID: r.ID, Name: r.Name, Kind: r.Kind, Grade: r.Grade, Description: r.Description,
|
a := &APIReveal{MoonID: r.ID, Name: r.Name, Kind: r.Kind, Description: r.Description,
|
||||||
Rating: *apiRating(&r.Rating),
|
Rating: *apiRating(&r.Rating),
|
||||||
Headline: r.Headline, Sub: r.Sub, Worth: r.Worth, Multiple: r.Multiple, Traits: []string{}, MaxSupply: r.MaxSupply,
|
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{Grade: r.Next.Grade, Price: r.Next.Price, CanBuy: r.Next.CanBuy, Why: r.Next.Why, WhyText: r.Next.WhyText}}
|
|
||||||
a.Traits = append(a.Traits, r.Traits...)
|
a.Traits = append(a.Traits, r.Traits...)
|
||||||
for _, w := range r.Wells {
|
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})
|
a.Wells = append(a.Wells, APIRevealWell{ID: w.ID, Resource: w.Resource, Supply: w.Supply, Full: w.Supply >= r.FullAt})
|
||||||
@@ -5,24 +5,27 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"io/fs"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAPI(t *testing.T) {
|
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 := newPlayerState()
|
||||||
unlocked.Onboarding = []string{"all"}
|
unlocked.Onboarding = []string{"all"}
|
||||||
asides := map[bool]bool{}
|
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} {
|
||||||
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
|
|
||||||
for _, v := range testViews(s) {
|
for _, v := range testViews(s) {
|
||||||
s.pagePastInMemory(v.Page)
|
|
||||||
p := s.page(v.resolve(s))
|
p := s.page(v.resolve(s))
|
||||||
testRanking(p)
|
testRanking(p)
|
||||||
p.Admin = len(s.Tools) > 0
|
|
||||||
a := apiState(p)
|
a := apiState(p)
|
||||||
if a.Admin != p.Admin {
|
|
||||||
t.Errorf("view %+v: admin %v", v, a.Admin)
|
|
||||||
}
|
|
||||||
if _, err := json.Marshal(a); err != nil {
|
if _, err := json.Marshal(a); err != nil {
|
||||||
t.Fatalf("marshal %+v: %v", v, err)
|
t.Fatalf("marshal %+v: %v", v, err)
|
||||||
}
|
}
|
||||||
@@ -43,7 +46,7 @@ func TestAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if a.Market != nil {
|
if a.Market != nil {
|
||||||
for _, q := range a.Market.Quotes {
|
for _, q := range a.Market.Quotes {
|
||||||
if q.SellAll > q.Balance*q.Unpressed+1e-6 || q.Value != q.Balance*q.Price || (q.Balance > 0 && q.SellAll <= 0) {
|
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)
|
t.Errorf("quote %s: sell all %v, value %v", q.Item, q.SellAll, q.Value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,8 +62,7 @@ func TestAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if p.Moon != nil {
|
if p.Moon != nil {
|
||||||
// The reveal card and the buy modal, as an action response maps them.
|
// 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)
|
t.Errorf("view %+v: reveal %+v", v, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,13 +90,6 @@ func TestAPI(t *testing.T) {
|
|||||||
if _, err := json.Marshal(all); err != nil {
|
if _, err := json.Marshal(all); err != nil {
|
||||||
t.Fatalf("marshal %+v with every section: %v", v, err)
|
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 {
|
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)
|
t.Errorf("view %+v: state has %+v", p.V, a.View)
|
||||||
}
|
}
|
||||||
@@ -102,25 +97,18 @@ func TestAPI(t *testing.T) {
|
|||||||
t.Errorf("view %+v: %d stakes, %d tools", v, len(a.Stakes), len(a.Tools))
|
t.Errorf("view %+v: %d stakes, %d tools", v, len(a.Stakes), len(a.Tools))
|
||||||
}
|
}
|
||||||
for _, st := range a.Stakes {
|
for _, st := range a.Stakes {
|
||||||
if st.End-st.Start != int64(math.Round(st.Secs*1000)) {
|
if st.End-st.Start != int64(st.Secs)*1000 {
|
||||||
t.Errorf("stake %s: end - start is not its duration", st.ID)
|
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) {
|
if (a.Moon != nil) != (p.Moon != nil) || (a.Well != nil) != (p.Well != nil) {
|
||||||
t.Errorf("view %+v: moon or well missing", v)
|
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") {
|
if (a.Market != nil) != s.Unlocked("market") {
|
||||||
t.Errorf("view %+v: market is %v, unlocked %v", v, 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())))
|
a := apiState(newPlayerState().page(View{}.resolve(newPlayerState())))
|
||||||
if a.Unlocked["market"] || !a.Unlocked["new-moon"] || a.View.Section != "new-moon" {
|
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)
|
t.Errorf("new player: unlocked %v, section %q", a.Unlocked, a.View.Section)
|
||||||
@@ -162,11 +150,8 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
|
|||||||
if len(w.Drills) > 0 && w.Label == "" {
|
if len(w.Drills) > 0 && w.Label == "" {
|
||||||
fail("well label", w)
|
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 {
|
for _, d := range w.Drills {
|
||||||
if d.Fate == "" || (d.Reach > 1) != (d.ReachText != "") {
|
if d.Fate == "" {
|
||||||
fail("drill fate", d)
|
fail("drill fate", d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,13 +160,6 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
|
|||||||
fail("moon sale", sale)
|
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 {
|
for _, b := range a.Bonuses {
|
||||||
if b.Label == "" || b.Text == "" {
|
if b.Label == "" || b.Text == "" {
|
||||||
fail("bonus", b)
|
fail("bonus", b)
|
||||||
@@ -191,28 +169,27 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
|
|||||||
if len(c.Reach) != len(c.Resources) {
|
if len(c.Reach) != len(c.Resources) {
|
||||||
fail("reach texts", c.Reach)
|
fail("reach texts", c.Reach)
|
||||||
}
|
}
|
||||||
for _, tool := range c.Tools {
|
for _, at := range c.Attachments {
|
||||||
if len(tool.Tracks) != 2 || tool.Tracks[0].Kind != "yield" || tool.Tracks[1].Kind != "cycle" {
|
if at.EffectText == "" {
|
||||||
fail("tool tracks", tool)
|
fail("attachment effect", at)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if e := a.Equipment; e != nil {
|
if e := a.Equipment; e != nil {
|
||||||
for _, r := range e.Tools {
|
for _, r := range e.Tools {
|
||||||
if (r.NoSale != "") != (r.NoSaleText != "") {
|
if (r.NoSale != "") != (r.NoSaleText != "") || r.Next != nil && (r.Next.Blocked != "") != (r.Next.BlockedText != "") {
|
||||||
fail("tool row texts", r)
|
fail("tool row texts", r)
|
||||||
}
|
}
|
||||||
for _, tr := range r.Tracks {
|
|
||||||
if n := tr.Next; n != nil && ((n.Blocked != "") != (n.BlockedText != "") || n.Label == "") ||
|
|
||||||
len(e.Ladders[r.LadderKey][tr.Kind]) != tr.Max+1 {
|
|
||||||
fail("track texts", tr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if d := e.Drill; d != nil {
|
if d := e.Drill; d != nil {
|
||||||
if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" {
|
if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" {
|
||||||
fail("drill panel texts", d)
|
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 {
|
if st := a.Store; st != nil {
|
||||||
@@ -240,10 +217,6 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
|
|||||||
if (up.Why != "") != (up.WhyText != "") || up.Why == "" && !up.CanHire {
|
if (up.Why != "") != (up.WhyText != "") || up.Why == "" && !up.CanHire {
|
||||||
fail("advisor texts", up)
|
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 {
|
if h := a.Help; h != nil && h.NewGame.Text != newGameConfirm {
|
||||||
@@ -269,9 +242,11 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
|
|||||||
t.Errorf("view %+v: equipment %+v", v, a.Equipment)
|
t.Errorf("view %+v: equipment %+v", v, a.Equipment)
|
||||||
} else if (a.Equipment.Drill != nil) != (v.Drill != "") {
|
} else if (a.Equipment.Drill != nil) != (v.Drill != "") {
|
||||||
t.Errorf("view %+v: drill panel %v", v, a.Equipment.Drill != nil)
|
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":
|
case "store":
|
||||||
if a.Store == nil || len(a.Store.Tools) != len(s.Config.StoreItems) {
|
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)
|
t.Errorf("view %+v: store %+v", v, a.Store)
|
||||||
}
|
}
|
||||||
case "refinery":
|
case "refinery":
|
||||||
@@ -279,15 +254,9 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
|
|||||||
t.Errorf("view %+v: refinery %+v", v, a.Refinery)
|
t.Errorf("view %+v: refinery %+v", v, a.Refinery)
|
||||||
}
|
}
|
||||||
case "advisors":
|
case "advisors":
|
||||||
if a.Advisors == nil || len(a.Advisors.Advisors) < len(cfg().Advisors.Items) || len(a.Advisors.SeatList) != s.AdvisorSeats ||
|
if a.Advisors == nil || len(a.Advisors.Advisors) != len(cfg().Advisors.Items) {
|
||||||
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)
|
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":
|
case "leaderboard":
|
||||||
if a.Leaderboard == nil || a.Leaderboard.Me == nil || a.Leaderboard.Me.Rank != 12 {
|
if a.Leaderboard == nil || a.Leaderboard.Me == nil || a.Leaderboard.Me.Rank != 12 {
|
||||||
t.Errorf("view %+v: leaderboard %+v", v, a.Leaderboard)
|
t.Errorf("view %+v: leaderboard %+v", v, a.Leaderboard)
|
||||||
@@ -306,45 +275,3 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSharedQuotes: two players with different selling pressure and advisors see the same
|
|
||||||
// quotes (and 24h history) but get different payouts.
|
|
||||||
func TestSharedQuotes(t *testing.T) {
|
|
||||||
c := cfg()
|
|
||||||
item := sortedKeys(c.ResourcePrices)[0]
|
|
||||||
var sellAdvisor string
|
|
||||||
for _, a := range c.Advisors.Items {
|
|
||||||
if a.Sell > 1 {
|
|
||||||
sellAdvisor = a.Name
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sellAdvisor == "" {
|
|
||||||
t.Skip("no advisor with a sell bonus in the config")
|
|
||||||
}
|
|
||||||
boosts := PriceBoosts{item: 1.5}
|
|
||||||
a, b := newPlayerState(), newPlayerState()
|
|
||||||
b.UserID = testID("u", 9)
|
|
||||||
b.Now = a.Now
|
|
||||||
a.PriceBoosts, b.PriceBoosts = boosts, boosts
|
|
||||||
b.Pressure = map[string]float64{item: c.MarketPressure.Scale}
|
|
||||||
b.Advisors = []string{sellAdvisor}
|
|
||||||
rows := []MarketRow{{ResourceBalance: ResourceBalance{Resource: item, Balance: 100}}}
|
|
||||||
qa, qb := a.marketPage(rows, View{Section: "market"}).Quotes[0], b.marketPage(rows, View{Section: "market"}).Quotes[0]
|
|
||||||
if qa.Price != qb.Price || qa.Value != qb.Value || qa.Change != qb.Change || qa.High != qb.High || qa.Low != qb.Low {
|
|
||||||
t.Errorf("quotes differ: %+v vs %+v", qa, qb)
|
|
||||||
}
|
|
||||||
if want := marketQuote(item, a.Now, 1.5); qa.Price != want {
|
|
||||||
t.Errorf("quote %v, want rate × boost %v", qa.Price, want)
|
|
||||||
}
|
|
||||||
if qa.Unit() != qa.Price || qa.SellAll() > qa.Balance*qa.Price {
|
|
||||||
t.Errorf("no modifiers, but unit %v / sellAll %v differ from quote %v", qa.Unit(), qa.SellAll(), qa.Price)
|
|
||||||
}
|
|
||||||
if !qb.Advised() || !qb.Pressed() || qb.Unit() == qa.Unit() || qb.SellAll() == qa.SellAll() {
|
|
||||||
t.Errorf("payouts should differ: %+v vs %+v", qa, qb)
|
|
||||||
}
|
|
||||||
want := sellPayout(qb.Balance, qb.Price, b.Bonus().Sell, c.MarketPressure.Average(qb.Pressure, qb.Balance*qb.Base()))
|
|
||||||
if math.Abs(qb.SellAll()-want) > 1e-9 {
|
|
||||||
t.Errorf("sellAll %v, want %v", qb.SellAll(), want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Drill attachment art
|
||||||
|
|
||||||
|
The attachment images (`web/static/assets/attachments/<image>`, `image` in
|
||||||
|
`config/game.json` `attachments.items`) are rendered in Blender (4.5 LTS, Cycles on
|
||||||
|
CPU) with the equipment pipeline: `art/equipment/lib.py` for materials, the regolith
|
||||||
|
ground, world, lights, camera and render settings.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
art/attachments/render_all.sh # all four
|
||||||
|
art/attachments/render_all.sh diamond-bit # just one
|
||||||
|
```
|
||||||
|
|
||||||
|
`render.py` builds and renders at 1024px, `finish.py` (system `python3` with Pillow)
|
||||||
|
writes the 512px PNGs and contact sheets in `build/`. `--res N` and `--samples N`
|
||||||
|
give quick previews.
|
||||||
|
|
||||||
|
`parts.py`: one build function per attachment and `PARTS` (key = image name, detail
|
||||||
|
scale `S`, ground seed).
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Turn raw 1024px renders into the 512px game images, plus contact sheets.
|
||||||
|
|
||||||
|
python3 art/attachments/finish.py [RAW_DIR] [--sheet DIR]
|
||||||
|
|
||||||
|
Needs Pillow. Writes web/static/assets/attachments/<name>.png for every <name>.png in RAW_DIR
|
||||||
|
(the names are the `image` slugs in config/game.json).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
OUT = os.path.normpath(os.path.join(HERE, '..', '..', 'web', 'static', 'assets', 'attachments'))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = sys.argv[1:]
|
||||||
|
sheet_dir = None
|
||||||
|
if '--sheet' in a:
|
||||||
|
i = a.index('--sheet')
|
||||||
|
sheet_dir = a[i + 1]
|
||||||
|
del a[i:i + 2]
|
||||||
|
raw = a[0] if a else os.path.join(HERE, 'build')
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
imgs = []
|
||||||
|
for fname in sorted(f for f in os.listdir(raw) if f.endswith('.png') and not f.startswith('contact_sheet')):
|
||||||
|
im = Image.open(os.path.join(raw, fname)).convert('RGB').resize((512, 512), Image.LANCZOS)
|
||||||
|
out = os.path.join(OUT, fname)
|
||||||
|
im.save(out, 'PNG', optimize=True)
|
||||||
|
if os.path.getsize(out) > 200_000:
|
||||||
|
q = im.quantize(256, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.FLOYDSTEINBERG)
|
||||||
|
q.save(out, 'PNG', optimize=True)
|
||||||
|
print(f'{fname}: {os.path.getsize(out) // 1024} KB')
|
||||||
|
imgs.append(Image.open(out).convert('RGB'))
|
||||||
|
if sheet_dir and imgs:
|
||||||
|
os.makedirs(sheet_dir, exist_ok=True)
|
||||||
|
for size, fn in ((512, 'contact_sheet.png'), (200, 'contact_sheet_200px.png')):
|
||||||
|
pad = 8
|
||||||
|
sheet = Image.new('RGB', (len(imgs) * (size + pad) + pad, size + 2 * pad), (4, 10, 22))
|
||||||
|
for i, im in enumerate(imgs):
|
||||||
|
sheet.paste(im.resize((size, size), Image.LANCZOS), (pad + i * (size + pad), pad))
|
||||||
|
sheet.save(os.path.join(sheet_dir, fn))
|
||||||
|
print('contact sheets in', sheet_dir)
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""The four drill attachments. Each build function draws into a lib.Builder (art/equipment).
|
||||||
|
|
||||||
|
Coordinates: metres, Z up, front faces -Y (the camera sits front-right, +X/-Y).
|
||||||
|
Keys are the image names (`image` in config/game.json `attachments.items`, minus .png).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from lib import CYAN, AMBER
|
||||||
|
|
||||||
|
PI = math.pi
|
||||||
|
|
||||||
|
|
||||||
|
# Turbo Core: an overdriven motor core. Finned housing, glowing copper windings, flange.
|
||||||
|
def turbo_core(b):
|
||||||
|
z0 = 0.05
|
||||||
|
# mounting flange and feet
|
||||||
|
b.cyl(0.17, 0.035, 'graphite', (0, 0, z0 + 0.0175))
|
||||||
|
for k in range(8):
|
||||||
|
a = k / 8 * 2 * PI + PI / 8
|
||||||
|
b.cyl(0.011, 0.012, 'chrome', (math.cos(a) * 0.145, math.sin(a) * 0.145, z0 + 0.04), seg=8)
|
||||||
|
for sx in (-1, 1):
|
||||||
|
b.box((0.06, 0.32, 0.05), 'dark', (sx * 0.14, 0, 0.025), bevel=0.008)
|
||||||
|
# housing: lower stator drum with radial fins
|
||||||
|
zc = z0 + 0.035
|
||||||
|
b.cyl(0.12, 0.22, 'steel', (0, 0, zc + 0.11))
|
||||||
|
for k in range(20):
|
||||||
|
a = k / 20 * 2 * PI
|
||||||
|
with b.at((0, 0, zc + 0.11), (0, 0, a)):
|
||||||
|
b.box((0.05, 0.008, 0.2), 'graphite', (0.135, 0, 0), bevel=0.002)
|
||||||
|
for z in (zc + 0.015, zc + 0.205):
|
||||||
|
b.torus(0.155, 0.012, 'graphite', (0, 0, z))
|
||||||
|
# exposed windings: glowing core behind a cage
|
||||||
|
zw = zc + 0.22
|
||||||
|
b.cyl(0.085, 0.13, 'core_hot', (0, 0, zw + 0.065))
|
||||||
|
b.helix((0, 0), 0.092, zw + 0.008, zw + 0.122, 7, 0.008, 'brass')
|
||||||
|
for k in range(8):
|
||||||
|
a = k / 8 * 2 * PI
|
||||||
|
b.strut((math.cos(a) * 0.105, math.sin(a) * 0.105, zw), (math.cos(a) * 0.105, math.sin(a) * 0.105, zw + 0.13),
|
||||||
|
0.006, 'chrome', seg=8)
|
||||||
|
b.light((0, -0.16, zw + 0.07), AMBER, 6, 0.03)
|
||||||
|
b.light((0.16, 0.0, zw + 0.07), AMBER, 3, 0.03)
|
||||||
|
# cap with turbine fan and shaft coupling
|
||||||
|
zt = zw + 0.13
|
||||||
|
b.cyl(0.115, 0.03, 'white', (0, 0, zt + 0.015))
|
||||||
|
b.cyl(0.08, 0.02, 'dark', (0, 0, zt + 0.04))
|
||||||
|
for k in range(12):
|
||||||
|
a = k / 12 * 2 * PI
|
||||||
|
with b.at((0, 0, zt + 0.045), (0, 0, a)):
|
||||||
|
b.box((0.065, 0.004, 0.012), 'metal', (0.04, 0, 0), (0.6, 0, 0), bevel=0.001)
|
||||||
|
b.cyl(0.03, 0.08, 'chrome', (0, 0, zt + 0.08))
|
||||||
|
b.cyl(0.04, 0.02, 'graphite', (0, 0, zt + 0.125))
|
||||||
|
for k in range(4):
|
||||||
|
a = k / 4 * 2 * PI
|
||||||
|
b.box((0.012, 0.012, 0.02), 'chrome', (math.cos(a) * 0.04, math.sin(a) * 0.04, zt + 0.14), (0, 0, a), bevel=0.002)
|
||||||
|
# status lamp, label plate and cables
|
||||||
|
b.box((0.08, 0.006, 0.04), 'white', (0.0, -0.157, zc + 0.12), bevel=0.002)
|
||||||
|
b.box((0.05, 0.004, 0.006), 'amber', (0.0, -0.161, zc + 0.12), bevel=0)
|
||||||
|
b.lamp((0.1, -0.1, zt + 0.035), 0.012, 'red', housing=False)
|
||||||
|
b.tube([(-0.12, 0.08, zw + 0.03), (-0.2, 0.12, zw - 0.05), (-0.24, 0.05, 0.02)], 0.012, 'rubber')
|
||||||
|
b.tube([(-0.12, 0.03, zw + 0.06), (-0.22, 0.02, zw - 0.02), (-0.26, -0.06, 0.02)], 0.009, 'orange')
|
||||||
|
|
||||||
|
|
||||||
|
# Diamond Bit: a fluted drill bit with a diamond-studded cutting crown, upright on its shank.
|
||||||
|
def diamond_bit(b):
|
||||||
|
# shank in a coupling block on the ground
|
||||||
|
b.box((0.2, 0.2, 0.05), 'graphite', (0, 0, 0.025), bevel=0.01)
|
||||||
|
b.hazard_band(0.21, 0.21, 0.015, (0, 0, 0.055))
|
||||||
|
b.cyl(0.065, 0.08, 'steel', (0, 0, 0.1))
|
||||||
|
for k in range(6):
|
||||||
|
a = k / 6 * 2 * PI
|
||||||
|
b.box((0.018, 0.035, 0.07), 'metal', (math.cos(a) * 0.066, math.sin(a) * 0.066, 0.1), (0, 0, a), bevel=0.004)
|
||||||
|
# fluted body
|
||||||
|
z0, z1 = 0.14, 0.44
|
||||||
|
b.cyl(0.045, z1 - z0, 'graphite', (0, 0, (z0 + z1) / 2))
|
||||||
|
for k in range(2):
|
||||||
|
pts = []
|
||||||
|
n = 48
|
||||||
|
for i in range(n + 1):
|
||||||
|
t = i / n
|
||||||
|
a = k * PI + t * 2.6 * PI
|
||||||
|
pts.append((math.cos(a) * 0.055, math.sin(a) * 0.055, z0 + (z1 - z0) * t))
|
||||||
|
b.tube(pts, 0.024, 'metal', seg=12, steps=2)
|
||||||
|
# crown: carbide ring with diamond studs, cone tip
|
||||||
|
zc = z1 + 0.01
|
||||||
|
b.lathe([(0.07, zc - 0.03), (0.09, zc), (0.09, zc + 0.035), (0.08, zc + 0.045), (0.0, zc + 0.045)], 'graphite')
|
||||||
|
b.lathe([(0.078, zc + 0.045), (0.05, zc + 0.09), (0.015, zc + 0.13), (0.0, zc + 0.135)], 'steel')
|
||||||
|
for k in range(12):
|
||||||
|
a = k / 12 * 2 * PI
|
||||||
|
b.sphere(0.014, 'diamond', (math.cos(a) * 0.089, math.sin(a) * 0.089, zc + 0.02), seg=6)
|
||||||
|
for k in range(7):
|
||||||
|
a = k / 7 * 2 * PI + 0.3
|
||||||
|
b.sphere(0.011, 'diamond', (math.cos(a) * 0.05, math.sin(a) * 0.05, zc + 0.088), seg=6)
|
||||||
|
b.sphere(0.01, 'diamond', (0, 0, zc + 0.136), seg=6)
|
||||||
|
b.light((0.0, -0.15, zc + 0.06), CYAN, 3, 0.02)
|
||||||
|
# a couple of loose diamonds on the ground
|
||||||
|
for x, y in ((0.16, -0.1), (-0.13, -0.15), (0.07, -0.17)):
|
||||||
|
b.sphere(0.013, 'diamond', (x, y, 0.008), seg=6)
|
||||||
|
|
||||||
|
|
||||||
|
# Cryo Cooler: a coolant jacket that wraps the spindle. Frosted canister, glowing coolant
|
||||||
|
# windows, coiled lines and a gauge.
|
||||||
|
def cryo_cooler(b):
|
||||||
|
b.cyl(0.16, 0.03, 'graphite', (0, 0, 0.015))
|
||||||
|
b.cyl(0.15, 0.015, 'cyan_soft', (0, 0, 0.035))
|
||||||
|
# jacket
|
||||||
|
z0, z1 = 0.045, 0.34
|
||||||
|
b.cyl(0.11, z1 - z0, 'frost', (0, 0, (z0 + z1) / 2))
|
||||||
|
for z in (z0 + 0.02, (z0 + z1) / 2, z1 - 0.02):
|
||||||
|
b.torus(0.112, 0.012, 'steel', (0, 0, z))
|
||||||
|
for k in range(4):
|
||||||
|
a = k / 4 * 2 * PI + PI / 4
|
||||||
|
with b.at((0, 0, 0), (0, 0, a)):
|
||||||
|
b.box((0.012, 0.04, 0.1), 'coolant', (0.0, -0.104, z0 + 0.075), bevel=0.003)
|
||||||
|
b.box((0.012, 0.04, 0.1), 'coolant', (0.0, -0.104, z1 - 0.075), bevel=0.003)
|
||||||
|
b.light((0, -0.25, 0.19), CYAN, 8, 0.05)
|
||||||
|
# spindle through the middle
|
||||||
|
b.cyl(0.035, 0.12, 'chrome', (0, 0, z1 + 0.06))
|
||||||
|
b.cyl(0.075, 0.03, 'white', (0, 0, z1 + 0.015))
|
||||||
|
b.cyl(0.045, 0.02, 'graphite', (0, 0, z1 + 0.12))
|
||||||
|
# coolant coil around the jacket and feed lines
|
||||||
|
b.helix((0, 0), 0.128, z0 + 0.04, z1 - 0.04, 4, 0.007, 'chrome')
|
||||||
|
b.tube([(0.12, 0.05, z1 - 0.04), (0.2, 0.08, z1 + 0.02), (0.24, 0.12, 0.1), (0.26, 0.2, 0.02)], 0.014, 'white')
|
||||||
|
b.tube([(-0.12, 0.05, z0 + 0.05), (-0.2, 0.1, 0.05), (-0.22, 0.2, 0.02)], 0.012, 'rubber')
|
||||||
|
# pressure gauge
|
||||||
|
with b.at((-0.08, -0.1, z1 - 0.05), (0, 0, -0.6)):
|
||||||
|
b.cyl(0.03, 0.02, 'steel', (0, -0.02, 0), axis='y')
|
||||||
|
b.cyl(0.024, 0.005, 'white', (0, -0.031, 0), axis='y')
|
||||||
|
b.box((0.003, 0.002, 0.02), 'red', (0.005, -0.034, 0.004), (0, 0.6, 0), bevel=0)
|
||||||
|
# frost chips on the ground
|
||||||
|
for x, y, r in ((0.18, -0.06, 0.016), (-0.17, -0.1, 0.012), (0.08, -0.18, 0.01)):
|
||||||
|
b.sphere(r, 'ice', (x, y, r * 0.4), seg=10, scale=(1.2, 1.0, 0.6))
|
||||||
|
|
||||||
|
|
||||||
|
# Seam Scanner: ground-penetrating radar on a sled. Antenna panel facing the ground,
|
||||||
|
# rotating dish, a scan display.
|
||||||
|
def seam_scanner(b):
|
||||||
|
# sled runners
|
||||||
|
for sx in (-1, 1):
|
||||||
|
b.box((0.03, 0.42, 0.025), 'dark', (sx * 0.15, 0, 0.0125), bevel=0.008)
|
||||||
|
b.strut((sx * 0.15, -0.21, 0.012), (sx * 0.15, -0.25, 0.05), 0.013, 'dark')
|
||||||
|
# radar body with glowing underside emitter
|
||||||
|
b.box((0.36, 0.34, 0.08), 'white', (0, 0, 0.075), bevel=0.02)
|
||||||
|
b.box((0.3, 0.28, 0.02), 'cyan', (0, 0, 0.032), bevel=0.004)
|
||||||
|
b.light((0, 0, 0.01), CYAN, 5, 0.1)
|
||||||
|
b.hazard_band(0.37, 0.02, 0.02, (0, -0.17, 0.085))
|
||||||
|
# electronics box
|
||||||
|
b.box((0.22, 0.2, 0.1), 'graphite', (0.02, 0.04, 0.165), bevel=0.012)
|
||||||
|
b.vent(0.12, 0.05, (0.131, 0.06, 0.165), (0, 0, PI / 2))
|
||||||
|
# display with a scan grid
|
||||||
|
with b.at((-0.02, -0.08, 0.23), (-0.5, 0, 0)):
|
||||||
|
b.box((0.16, 0.012, 0.11), 'dark', (0, 0, 0), bevel=0.006)
|
||||||
|
b.box((0.14, 0.004, 0.09), 'glass', (0, -0.007, 0), bevel=0)
|
||||||
|
# mast with a dish
|
||||||
|
b.cyl(0.018, 0.16, 'steel', (0.08, 0.1, 0.29))
|
||||||
|
b.cyl(0.03, 0.02, 'graphite', (0.08, 0.1, 0.37))
|
||||||
|
b.lathe([(0.0, 0.0), (0.05, 0.012), (0.09, 0.04)], 'white', (0.08, 0.1, 0.39), (0.5, 0.0, -0.4))
|
||||||
|
b.strut((0.08, 0.1, 0.39), (0.08 - 0.02, 0.1 - 0.035, 0.45), 0.004, 'graphite')
|
||||||
|
b.lamp((0.06, 0.065, 0.455), 0.01, 'cyan', housing=False)
|
||||||
|
# antenna whips and lamp
|
||||||
|
b.strut((-0.13, 0.12, 0.215), (-0.15, 0.14, 0.42), 0.004, 'graphite')
|
||||||
|
b.lamp((-0.15, 0.14, 0.425), 0.009, 'amber', housing=False)
|
||||||
|
b.lamp((0.14, -0.1, 0.13), 0.01, 'amber')
|
||||||
|
# cable reel at the back
|
||||||
|
b.cyl(0.05, 0.05, 'orange', (-0.1, 0.13, 0.17), axis='x')
|
||||||
|
b.cyl(0.035, 0.055, 'rubber', (-0.1, 0.13, 0.17), axis='x')
|
||||||
|
|
||||||
|
|
||||||
|
PARTS = {
|
||||||
|
'turbo-core': {'build': turbo_core, 'S': 0.3, 'seed': 11},
|
||||||
|
'diamond-bit': {'build': diamond_bit, 'S': 0.25, 'seed': 12, 'fill': 0.8},
|
||||||
|
'cryo-cooler': {'build': cryo_cooler, 'S': 0.3, 'seed': 13},
|
||||||
|
'seam-scanner': {'build': seam_scanner, 'S': 0.3, 'seed': 14},
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Build and render the drill attachment icons.
|
||||||
|
|
||||||
|
blender -b --factory-startup --python art/attachments/render.py -- [names...|all] [--out DIR]
|
||||||
|
[--res N] [--samples N]
|
||||||
|
|
||||||
|
Raw renders go to --out (default art/attachments/build); finish.py turns them into the
|
||||||
|
game images. Uses art/equipment/lib.py, so the parts match the store equipment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
sys.path.insert(0, os.path.join(HERE, '..', 'equipment'))
|
||||||
|
|
||||||
|
import lib # noqa: E402
|
||||||
|
import parts # noqa: E402
|
||||||
|
from lib import srgb # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def args():
|
||||||
|
a = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
|
||||||
|
opts = {'out': os.path.join(HERE, 'build'), 'res': 1024, 'samples': 96}
|
||||||
|
names = []
|
||||||
|
i = 0
|
||||||
|
while i < len(a):
|
||||||
|
k = a[i]
|
||||||
|
if k == '--out':
|
||||||
|
opts['out'] = a[i + 1]; i += 2
|
||||||
|
elif k in ('--res', '--samples'):
|
||||||
|
opts[k[2:]] = int(a[i + 1]); i += 2
|
||||||
|
else:
|
||||||
|
names.append(k); i += 1
|
||||||
|
if not names or names == ['all']:
|
||||||
|
names = list(parts.PARTS)
|
||||||
|
return names, opts
|
||||||
|
|
||||||
|
|
||||||
|
def materials(S):
|
||||||
|
M = lib.make_materials(S)
|
||||||
|
M['core_hot'] = lib.emissive('core_hot', srgb('#ff8a2b'), 9.0, base=srgb('#5a2a0a'))
|
||||||
|
M['diamond'] = lib.simple('diamond', srgb('#e8f8ff'), metal=0.3, rough=0.03, coat=1.0,
|
||||||
|
emission=srgb('#9fe8ff'), estrength=0.35)
|
||||||
|
M['frost'] = lib.paint('frost', srgb('#e4f1f8'), S, rough=0.55, panels=False, wear=0.2, dirt=0.25)
|
||||||
|
M['coolant'] = lib.emissive('coolant', srgb('#5fd4ff'), 5.0, base=srgb('#10303c'))
|
||||||
|
M['ice'] = lib.simple('ice', srgb('#cfeefc'), rough=0.15, coat=1.0, emission=srgb('#5fd4ff'), estrength=0.3)
|
||||||
|
return M
|
||||||
|
|
||||||
|
|
||||||
|
def build_scene(name):
|
||||||
|
spec = parts.PARTS[name]
|
||||||
|
lib.reset()
|
||||||
|
coll = bpy.context.scene.collection
|
||||||
|
S = spec['S']
|
||||||
|
b = lib.Builder(name, materials(S), S)
|
||||||
|
spec['build'](b)
|
||||||
|
objs = b.finish(coll)
|
||||||
|
return spec, objs, coll
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
names, o = args()
|
||||||
|
for name in names:
|
||||||
|
t0 = time.time()
|
||||||
|
spec, objs, coll = build_scene(name)
|
||||||
|
pts = lib.machine_points(objs)
|
||||||
|
lo = [min(p[i] for p in pts) for i in range(3)]
|
||||||
|
hi = [max(p[i] for p in pts) for i in range(3)]
|
||||||
|
span = max(hi[0] - lo[0], hi[1] - lo[1])
|
||||||
|
lib.build_ground(coll, spec['S'], span * spec.get('ground', 0.9), ((lo[0], lo[1]), (hi[0], hi[1])),
|
||||||
|
seed=spec.get('seed', 3))
|
||||||
|
lib.build_world()
|
||||||
|
lib.add_lights(coll)
|
||||||
|
lib.setup_camera(coll, pts, fill=spec.get('fill', 0.78), aim=spec.get('aim', (0.5, 0.5)))
|
||||||
|
lib.setup_render(o['res'], o['samples'])
|
||||||
|
os.makedirs(o['out'], exist_ok=True)
|
||||||
|
sc = bpy.context.scene
|
||||||
|
sc.render.filepath = os.path.join(o['out'], name + '.png')
|
||||||
|
bpy.ops.render.render(write_still=True)
|
||||||
|
print(f'[attachments] {name}: {time.time() - t0:.1f}s', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Render every attachments image (or the named ones) and write the game images.
|
||||||
|
# art/attachments/render_all.sh [name ...]
|
||||||
|
set -e
|
||||||
|
here=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
blender=${BLENDER:-blender}
|
||||||
|
[ $# -eq 0 ] && set -- all
|
||||||
|
"$blender" -b --factory-startup --python "$here/render.py" -- "$@" --out "$here/build"
|
||||||
|
python3 "$here/finish.py" "$here/build" --sheet "$here/build"
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Drill attachments: equipment bought in the Store that goes into the player's inventory,
|
||||||
|
// then is equipped on one drill in the Equipment section (the drill's side panel). An
|
||||||
|
// attachment's modifier makes the drill's stakes faster (speed) or bigger (yield). A drill
|
||||||
|
// has attachments.slotsPerTool slots and takes at most one of each kind. Equipping and
|
||||||
|
// unequipping work any time, also while the drill mines: like every bonus (bonus.go), it
|
||||||
|
// counts from the drill's next stake. Table attachment (inventory_item_id null: unequipped).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AttachmentConfig struct {
|
||||||
|
SlotsPerTool int `json:"slotsPerTool"`
|
||||||
|
Items []Attachment `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image"` // in web/static/assets/attachments
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Speed float64 `json:"speed,omitempty"` // modifiers (multipliers); unset is 1
|
||||||
|
Yield float64 `json:"yield,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GameConfig) attachment(name string) *Attachment {
|
||||||
|
for i := range c.Attachments.Items {
|
||||||
|
if c.Attachments.Items[i].Name == name {
|
||||||
|
return &c.Attachments.Items[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effect describes the modifier, e.g. "+20% speed".
|
||||||
|
func (a *Attachment) Effect() string {
|
||||||
|
var parts []string
|
||||||
|
if a.Speed > 1 {
|
||||||
|
parts = append(parts, pctText(a.Speed)+" speed")
|
||||||
|
}
|
||||||
|
if a.Yield > 1 {
|
||||||
|
parts = append(parts, pctText(a.Yield)+" yield")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// buyAttachment buys an attachment into the player's inventory (unequipped).
|
||||||
|
func buyAttachment(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
|
||||||
|
a := cfg().attachment(name)
|
||||||
|
if a == nil {
|
||||||
|
return gameErr("That attachment isn't for sale")
|
||||||
|
}
|
||||||
|
return inTx(ctx, db, func(tx pgx.Tx) error {
|
||||||
|
if err := debit(ctx, tx, userID, a.Price); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := tx.Exec(ctx, "insert into attachment(user_id, item) values ($1, $2)", userID, a.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := unlock(ctx, tx, userID, "advisors"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return logEvent(ctx, tx, userID, "buy_attachment", map[string]any{"item": a.Name, "price": a.Price})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockTool locks one of the player's tools.
|
||||||
|
func lockTool(ctx context.Context, tx pgx.Tx, userID, toolID string) error {
|
||||||
|
if !uuidPattern.MatchString(toolID) {
|
||||||
|
return gameErr("Tool not found")
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
select id from inventory_item where id = $1 and user_id = $2 for update`, toolID, userID).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return gameErr("Tool not found")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// equipAttachment puts one of the player's spare attachments of a kind on a tool (an idle
|
||||||
|
// copy of it, if there is one: see pickTool) and returns the tool it went on.
|
||||||
|
func equipAttachment(ctx context.Context, db *pgxpool.Pool, userID, name, toolID string) (string, error) {
|
||||||
|
a := cfg().attachment(name)
|
||||||
|
if a == nil {
|
||||||
|
return "", gameErr("Unknown attachment")
|
||||||
|
}
|
||||||
|
err := inTx(ctx, db, func(tx pgx.Tx) error {
|
||||||
|
// The tool row first, then its attachments.
|
||||||
|
var err error
|
||||||
|
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
select item from attachment where inventory_item_id = $1 for update`, toolID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fitted, err := pgx.CollectRows(rows, pgx.RowTo[string])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, f := range fitted {
|
||||||
|
if f == a.Name {
|
||||||
|
return gameErr("This drill already has a %s", a.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fitted) >= cfg().Attachments.SlotsPerTool {
|
||||||
|
return gameErr("This drill's slots are full. Unequip one first")
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
select id from attachment
|
||||||
|
where user_id = $1 and item = $2 and inventory_item_id is null
|
||||||
|
limit 1 for update skip locked`, userID, a.Name).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return gameErr("You have no spare %s. Buy one in the store", a.Name)
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, "update attachment set inventory_item_id = $2 where id = $1", id, toolID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return logEvent(ctx, tx, userID, "equip", map[string]any{"item": a.Name, "tool": toolID})
|
||||||
|
})
|
||||||
|
return toolID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// unequipAttachment takes an attachment of a kind off a tool (an idle copy of it, if there
|
||||||
|
// is one), back into the inventory, and returns the tool it came off.
|
||||||
|
func unequipAttachment(ctx context.Context, db *pgxpool.Pool, userID, name, toolID string) (string, error) {
|
||||||
|
err := inTx(ctx, db, func(tx pgx.Tx) error {
|
||||||
|
var err error
|
||||||
|
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
update attachment set inventory_item_id = null
|
||||||
|
where inventory_item_id = $1 and item = $2`, toolID, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return gameErr("That attachment isn't on this drill")
|
||||||
|
}
|
||||||
|
return logEvent(ctx, tx, userID, "unequip", map[string]any{"item": name, "tool": toolID})
|
||||||
|
})
|
||||||
|
return toolID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ view
|
||||||
|
|
||||||
|
// gear is the attachments equipped on a tool, in config order.
|
||||||
|
func (s *State) gear(toolID string) []*Attachment {
|
||||||
|
var out []*Attachment
|
||||||
|
for i := range cfg().Attachments.Items {
|
||||||
|
a := &cfg().Attachments.Items[i]
|
||||||
|
for _, name := range s.Fitted[toolID] {
|
||||||
|
if name == a.Name {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachmentCard is an attachment in the store.
|
||||||
|
type AttachmentCard struct {
|
||||||
|
*Attachment
|
||||||
|
Spare, Equipped int
|
||||||
|
CanAfford bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) attachmentCards() []AttachmentCard {
|
||||||
|
var cards []AttachmentCard
|
||||||
|
for i := range cfg().Attachments.Items {
|
||||||
|
a := &cfg().Attachments.Items[i]
|
||||||
|
card := AttachmentCard{Attachment: a, Spare: s.Spare[a.Name], CanAfford: s.Balance >= a.Price}
|
||||||
|
for _, names := range s.Fitted {
|
||||||
|
for _, n := range names {
|
||||||
|
if n == a.Name {
|
||||||
|
card.Equipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cards = append(cards, card)
|
||||||
|
}
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrillPanel is the side panel for a drill selected in the Equipment section: its slots
|
||||||
|
// and the spare attachments that fit, with the stats they'd give.
|
||||||
|
type DrillPanel struct {
|
||||||
|
ToolRow
|
||||||
|
Slots []SlotGear
|
||||||
|
Spares []SpareGear
|
||||||
|
Mining bool
|
||||||
|
Resale float64 // what it sells for (resale.go)
|
||||||
|
NoSale string // why it can't be sold, if it can't
|
||||||
|
|
||||||
|
// While it mines: the idle tools of its type, the best one and its best well.
|
||||||
|
IdleSame int
|
||||||
|
IdleBest *Tool
|
||||||
|
DeployTo *Suggestion
|
||||||
|
IdleURL string // opens IdleBest's panel
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlotGear is one attachment slot; Gear is nil when it's empty.
|
||||||
|
type SlotGear struct {
|
||||||
|
N int
|
||||||
|
Gear *Attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpareGear is a kind of spare attachment and the drill's stats with it equipped.
|
||||||
|
type SpareGear struct {
|
||||||
|
*Attachment
|
||||||
|
Count int
|
||||||
|
Secs int
|
||||||
|
Yield float64
|
||||||
|
CanEquip bool
|
||||||
|
Why string // why it can't be equipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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:
|
||||||
|
return "only-tool"
|
||||||
|
case row.Stake != nil && row.Stake.Ready:
|
||||||
|
return "claim-first"
|
||||||
|
case row.Stake != nil:
|
||||||
|
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) {
|
||||||
|
slot.Gear = row.Gear[i]
|
||||||
|
}
|
||||||
|
d.Slots = append(d.Slots, slot)
|
||||||
|
}
|
||||||
|
for i := range cfg().Attachments.Items {
|
||||||
|
a := &cfg().Attachments.Items[i]
|
||||||
|
n := s.Spare[a.Name]
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sg := SpareGear{Attachment: a, Count: n, CanEquip: true}
|
||||||
|
names := append([]string{a.Name}, s.Fitted[row.ID]...)
|
||||||
|
sg.Secs, sg.Yield = drillStats(row.Item.CompletionTimeInSecs, row.Item.ToolYield(row.Tier),
|
||||||
|
cfg().bonusesFor(s.Advisors, names), s.CycleBoosts.For("all"), s.YieldBoosts.For(""))
|
||||||
|
for _, g := range row.Gear {
|
||||||
|
if g.Name == a.Name {
|
||||||
|
sg.CanEquip, sg.Why = false, "Already on this drill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sg.CanEquip && row.FreeSlots == 0 {
|
||||||
|
sg.CanEquip, sg.Why = false, "Slots full. Unequip one first"
|
||||||
|
}
|
||||||
|
d.Spares = append(d.Spares, sg)
|
||||||
|
}
|
||||||
|
if d.Mining {
|
||||||
|
// Highest tier, then most attachments: the one pickTool would deploy.
|
||||||
|
for i := range s.Tools {
|
||||||
|
t := &s.Tools[i]
|
||||||
|
if t.StoreItemID != row.StoreItemID || s.StakeForTool(t.ID) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d.IdleSame++
|
||||||
|
if b := d.IdleBest; b == nil || t.Tier > b.Tier ||
|
||||||
|
(t.Tier == b.Tier && len(s.Fitted[t.ID]) > len(s.Fitted[b.ID])) {
|
||||||
|
d.IdleBest = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.IdleBest != nil {
|
||||||
|
d.IdleURL = View{Section: "equipment", Drill: d.IdleBest.ID}.URL()
|
||||||
|
if m := s.buySuggestions(d.IdleBest.ID); m != nil && len(m.Suggestions) > 0 {
|
||||||
|
d.DeployTo = &m.Suggestions[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -20,16 +20,17 @@ func runSim(args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
a := analyze(&c, nil, 0)
|
a := analyze(&c, nil, 0)
|
||||||
printGradeSim(&c)
|
fmt.Printf("avg price %.2f avg well %.0f units (%.2f wells) moon: price %s, ore %.0f units worth %s (%.2f×)\n",
|
||||||
fmt.Printf("\navg price %.2f default grade: avg well %.0f units (%.2f wells)\n", a.AvgPrice, a.AvgSupply, a.AvgWells)
|
a.AvgPrice, a.AvgSupply, a.AvgWells, formatCredits(c.MoonPrice), a.MoonUnits, formatCredits(a.MoonValue),
|
||||||
|
a.MoonValue/max(1, c.MoonPrice))
|
||||||
fmt.Println("(on moon: what a claim gets on average from a fresh moon, given what the wells in its reach hold; ₥/h at that)")
|
fmt.Println("(on moon: what a claim gets on average from a fresh moon, given what the wells in its reach hold; ₥/h at that)")
|
||||||
fmt.Printf("\n%-38s %8s %5s %6s %5s %7s %8s %8s %7s\n", "tool", "price", "secs", "claim", "wells", "on moon", "₥/h", "payback", "ratio")
|
fmt.Printf("\n%-38s %8s %5s %6s %5s %7s %8s %8s %7s\n", "tool", "price", "secs", "claim", "wells", "on moon", "₥/h", "payback", "ratio")
|
||||||
for _, t := range a.Tools {
|
for _, t := range a.Tools {
|
||||||
fmt.Printf("%-38s %8.0f %5d %6.0f %5d %7.0f %8.0f %8s %7s\n", t.Name, t.Price, t.Secs, t.Claim,
|
fmt.Printf("%-38s %8.0f %5d %6.0f %5d %7.0f %8.0f %8s %7s\n", t.Name, t.Price, t.Secs, t.Claim,
|
||||||
t.Reach, t.Eff, t.CreditsPerHour, t.Payback, t.Ratio)
|
t.Reach, t.Eff, t.CreditsPerHour, t.Payback, t.Ratio)
|
||||||
for _, u := range t.Levels {
|
for _, u := range t.Tiers {
|
||||||
fmt.Printf(" %-5s %d %8.0f + parts %5.0f yield %5.0f cycle %4.1fs on moon %5.0f %9.0f ₥/h payback %s\n",
|
fmt.Printf(" tier %d %6.0f + parts %5.0f yield %5.0f wells %d on moon %5.0f %8.0f ₥/h payback %s\n",
|
||||||
u.Track, u.Level, u.Price, u.PartsValue, u.Yield, u.Secs, u.Eff, u.CreditsPerHour, u.Payback)
|
u.Tier, u.Price, u.PartsValue, u.Yield, u.Reach, u.Eff, u.CreditsPerHour, u.Payback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println("\nrefinery")
|
fmt.Println("\nrefinery")
|
||||||
@@ -40,8 +41,30 @@ func runSim(args []string) error {
|
|||||||
for _, s := range a.Refining.Slots {
|
for _, s := range a.Refining.Slots {
|
||||||
fmt.Printf(" slot %d %s payback %s\n", s.Slot, formatCredits(s.Price), s.Payback)
|
fmt.Printf(" slot %d %s payback %s\n", s.Slot, formatCredits(s.Price), s.Payback)
|
||||||
}
|
}
|
||||||
if os.Getenv("OLDSIM") != "" {
|
s := a.Sim
|
||||||
printOldSim(a.Sim)
|
fmt.Println("\nsim")
|
||||||
|
for _, t := range s.Tools {
|
||||||
|
fmt.Printf(" %-38s afford %8s bought %8s ×%d\n", t.Name, t.AffordAt, t.BoughtAt, t.Count)
|
||||||
|
}
|
||||||
|
for _, m := range s.Attachments {
|
||||||
|
fmt.Printf(" attachment %-27s bought %8s ×%d\n", m.Name, m.BoughtAt, m.Count)
|
||||||
|
}
|
||||||
|
for _, a := range s.Advisors {
|
||||||
|
fmt.Printf(" advisor %-29s hired %8s\n", a.Name, a.BoughtAt)
|
||||||
|
}
|
||||||
|
fmt.Printf(" advisor seats %d\n", s.Seats)
|
||||||
|
for _, m := range s.Moons {
|
||||||
|
fmt.Printf(" moon #%d at %s\n", m.Count, m.At)
|
||||||
|
}
|
||||||
|
for _, sn := range s.Snapshots {
|
||||||
|
fmt.Printf(" %s credits %10.0f earned %10.0f rate %9.0f/h tools %3d moons %3d (+%d) pressure %s\n",
|
||||||
|
sn.At, sn.Credits, sn.Earned, sn.RatePerHour, sn.Tools, sn.Moons, sn.MoonsBought, sn.PressureText())
|
||||||
|
}
|
||||||
|
if os.Getenv("EVENTS") != "" {
|
||||||
|
for _, e := range s.Events {
|
||||||
|
fmt.Println(" " + e)
|
||||||
|
}
|
||||||
|
fmt.Printf(" (+%d more)\n", s.MoreEvents)
|
||||||
}
|
}
|
||||||
fmt.Println("\nwarnings")
|
fmt.Println("\nwarnings")
|
||||||
for _, w := range a.Warnings {
|
for _, w := range a.Warnings {
|
||||||
@@ -49,15 +72,3 @@ func runSim(args []string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// printOldSim prints the economy page's progression sim (sim.go).
|
|
||||||
func printOldSim(s *SimResult) {
|
|
||||||
fmt.Println("\nsim")
|
|
||||||
for _, t := range s.Tools {
|
|
||||||
fmt.Printf(" %-38s afford %8s bought %8s ×%d\n", t.Name, t.AffordAt, t.BoughtAt, t.Count)
|
|
||||||
}
|
|
||||||
for _, sn := range s.Snapshots {
|
|
||||||
fmt.Printf(" %s credits %10.0f earned %10.0f rate %9.0f/h tools %3d moons %3d (+%d) pressure %s\n",
|
|
||||||
sn.At, sn.Credits, sn.Earned, sn.RatePerHour, sn.Tools, sn.Moons, sn.MoonsBought, sn.PressureText())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// Player bonuses: advisors (advisors.go, for the whole operation). Each bonus is a
|
// Player bonuses: drill attachments (attachments.go, equipped on one tool; their bonus is
|
||||||
// multiplier (1.1 = +10%). A player's advisors multiply, then each kind is capped by bonusCaps
|
// the modifier) and advisors (advisors.go, for the whole operation). Each bonus is a
|
||||||
|
// multiplier (1.1 = +10%). Within a player's attachments and advisors they multiply, then each kind is capped by bonusCaps
|
||||||
// in the config; live events (events.go) multiply on top, uncapped. Bonuses are applied
|
// in the config; live events (events.go) multiply on top, uncapped. Bonuses are applied
|
||||||
// when an action runs: a stake fixes its duration and amount when it starts.
|
// when an action runs: a stake fixes its duration and amount when it starts.
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BonusCaps are the highest combined multipliers a player's advisors give.
|
// BonusCaps are the highest combined multipliers a player's attachments and advisors give.
|
||||||
type BonusCaps struct {
|
type BonusCaps struct {
|
||||||
Speed float64 `json:"speed"` // drill speed: cycle duration is divided by it
|
Speed float64 `json:"speed"` // drill speed: cycle duration is divided by it
|
||||||
Yield float64 `json:"yield"` // resources per stake
|
Yield float64 `json:"yield"` // resources per stake
|
||||||
@@ -37,8 +38,8 @@ func mult(v float64) float64 {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// bonusesFor combines the named advisors, capped.
|
// bonusesFor combines the named advisors and attachments, capped.
|
||||||
func (c *GameConfig) bonusesFor(advisors []string) Bonus {
|
func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
|
||||||
b := noBonus
|
b := noBonus
|
||||||
for _, name := range advisors {
|
for _, name := range advisors {
|
||||||
if a := c.advisor(name); a != nil {
|
if a := c.advisor(name); a != nil {
|
||||||
@@ -48,6 +49,12 @@ func (c *GameConfig) bonusesFor(advisors []string) Bonus {
|
|||||||
b.Refine *= mult(a.Refine)
|
b.Refine *= mult(a.Refine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, name := range attachments {
|
||||||
|
if m := c.attachment(name); m != nil {
|
||||||
|
b.Speed *= mult(m.Speed)
|
||||||
|
b.Yield *= mult(m.Yield)
|
||||||
|
}
|
||||||
|
}
|
||||||
b.Speed = min(b.Speed, c.BonusCaps.Speed)
|
b.Speed = min(b.Speed, c.BonusCaps.Speed)
|
||||||
b.Yield = min(b.Yield, c.BonusCaps.Yield)
|
b.Yield = min(b.Yield, c.BonusCaps.Yield)
|
||||||
b.Sell = min(b.Sell, c.BonusCaps.Sell)
|
b.Sell = min(b.Sell, c.BonusCaps.Sell)
|
||||||
@@ -55,10 +62,10 @@ func (c *GameConfig) bonusesFor(advisors []string) Bonus {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// drillStats is a tool's cycle (seconds, to a tenth, at least 1) and yield per stake with
|
// drillStats is a tool's cycle (seconds) and yield per stake with the player's bonus and
|
||||||
// the player's bonus and the live event multipliers. stakeInTx and the views both use it.
|
// the live event multipliers. stakeInTx and the views both use it.
|
||||||
func drillStats(secs, yield float64, b Bonus, eventSpeed, eventYield float64) (float64, float64) {
|
func drillStats(secs int, yield float64, b Bonus, eventSpeed, eventYield float64) (int, float64) {
|
||||||
return max(1, roundSecs(secs/(b.Speed*eventSpeed))), yield * b.Yield * eventYield
|
return max(1, int(math.Round(float64(secs)/(b.Speed*eventSpeed)))), yield * b.Yield * eventYield
|
||||||
}
|
}
|
||||||
|
|
||||||
// pctText formats a multiplier as a bonus, e.g. 1.1 → "+10%".
|
// pctText formats a multiplier as a bonus, e.g. 1.1 → "+10%".
|
||||||
@@ -74,12 +81,25 @@ func (cfg *GameConfig) validateBonuses() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
okMult := func(v float64) bool { return v == 0 || v >= 1 }
|
okMult := func(v float64) bool { return v == 0 || v >= 1 }
|
||||||
|
if len(cfg.Attachments.Items) > 0 && cfg.Attachments.SlotsPerTool < 1 {
|
||||||
|
return fmt.Errorf("attachments: slotsPerTool must be at least 1")
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, m := range cfg.Attachments.Items {
|
||||||
|
if m.Name == "" || names[m.Name] {
|
||||||
|
return fmt.Errorf("attachments: names must be set and unique (%q)", m.Name)
|
||||||
|
}
|
||||||
|
names[m.Name] = true
|
||||||
|
if m.Price < 0 || !okMult(m.Speed) || !okMult(m.Yield) {
|
||||||
|
return fmt.Errorf("attachment %q: price can't be negative, speed and yield must be at least 1", m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
for i, p := range cfg.Advisors.SeatPrices {
|
for i, p := range cfg.Advisors.SeatPrices {
|
||||||
if p < 0 {
|
if p < 0 {
|
||||||
return fmt.Errorf("advisors: seat %d price is negative", i+2)
|
return fmt.Errorf("advisors: seat %d price is negative", i+2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
names := map[string]bool{}
|
names = map[string]bool{}
|
||||||
for _, a := range cfg.Advisors.Items {
|
for _, a := range cfg.Advisors.Items {
|
||||||
if a.Name == "" || names[a.Name] {
|
if a.Name == "" || names[a.Name] {
|
||||||
return fmt.Errorf("advisors: names must be set and unique (%q)", a.Name)
|
return fmt.Errorf("advisors: names must be set and unique (%q)", a.Name)
|
||||||
@@ -94,11 +114,12 @@ func (cfg *GameConfig) validateBonuses() error {
|
|||||||
|
|
||||||
// ------------------------------------------------------------------ in a transaction
|
// ------------------------------------------------------------------ in a transaction
|
||||||
|
|
||||||
// loadBonus reads the player's seated advisors and combines them. The advisor rows are
|
// loadBonus reads the player's advisors and, with a tool id, the tool's attachments, and
|
||||||
|
// combines them. Callers that pass a tool have locked its row; the advisor rows are
|
||||||
// share-locked so a hire can't change them halfway through the action.
|
// share-locked so a hire can't change them halfway through the action.
|
||||||
func loadBonus(ctx context.Context, tx pgx.Tx, userID string) (Bonus, error) {
|
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
|
||||||
rows, err := tx.Query(ctx, `
|
rows, err := tx.Query(ctx, `
|
||||||
select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
|
select advisor from advisor_hire where user_id = $1 order by advisor for share`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return noBonus, err
|
return noBonus, err
|
||||||
}
|
}
|
||||||
@@ -106,54 +127,72 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID string) (Bonus, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return noBonus, err
|
return noBonus, err
|
||||||
}
|
}
|
||||||
return cfg().bonusesFor(advisors), nil
|
var mods []string
|
||||||
|
if toolID != "" {
|
||||||
|
rows, err = tx.Query(ctx, `
|
||||||
|
select item from attachment where inventory_item_id = $1 order by item`, toolID)
|
||||||
|
if err != nil {
|
||||||
|
return noBonus, err
|
||||||
|
}
|
||||||
|
if mods, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
|
||||||
|
return noBonus, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg().bonusesFor(advisors, mods), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ state
|
// ------------------------------------------------------------------ state
|
||||||
|
|
||||||
// loadBonusState loads the player's advisors (hired, and the seated ones) and
|
// loadBonusState loads the player's attachments, advisors and advisor seats into the state.
|
||||||
// advisor seats into the state.
|
|
||||||
func loadBonusState(ctx context.Context, db querier, s *State) error {
|
func loadBonusState(ctx context.Context, db querier, s *State) error {
|
||||||
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
|
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rows, err := db.Query(ctx, `
|
rows, err := db.Query(ctx, `
|
||||||
select advisor, coalesce(seat, 0) from advisor_hire where user_id = $1
|
select advisor from advisor_hire where user_id = $1 order by hired_at, advisor`, s.UserID)
|
||||||
order by seat nulls last, hired_at, advisor`, s.UserID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if s.Hires, err = pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire]); err != nil {
|
if s.Advisors, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.Advisors = nil
|
rows, err = db.Query(ctx, `
|
||||||
for _, h := range s.Hires {
|
select coalesce(inventory_item_id::text, ''), item from attachment
|
||||||
if h.Seat > 0 {
|
where user_id = $1 order by item`, s.UserID)
|
||||||
s.Advisors = append(s.Advisors, h.Name)
|
if err != nil {
|
||||||
}
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
s.Fitted, s.Spare = map[string][]string{}, map[string]int{}
|
||||||
|
var toolID, item string
|
||||||
|
_, err = pgx.ForEachRow(rows, []any{&toolID, &item}, func() error {
|
||||||
|
if toolID == "" {
|
||||||
|
s.Spare[item]++
|
||||||
|
} else {
|
||||||
|
s.Fitted[toolID] = append(s.Fitted[toolID], item)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bonus is the player's advisor bonus.
|
// Bonus is the player's advisor bonus (no attachments).
|
||||||
func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors) }
|
func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors, nil) }
|
||||||
|
|
||||||
|
// toolBonus is the bonus for stakes of one tool: advisors and its attachments.
|
||||||
|
func (s *State) toolBonus(toolID string) Bonus {
|
||||||
|
return cfg().bonusesFor(s.Advisors, s.Fitted[toolID])
|
||||||
|
}
|
||||||
|
|
||||||
// drill is what a stake of the tool on a well of the resource would take and return now.
|
// drill is what a stake of the tool on a well of the resource would take and return now.
|
||||||
func (s *State) drill(t Tool, item *StoreItem, resource string) (float64, float64) {
|
func (s *State) drill(t Tool, item *StoreItem, resource string) (int, float64) {
|
||||||
return s.drillAt(item, t.Levels(), resource)
|
return drillStats(item.CompletionTimeInSecs, item.ToolYield(t.Tier), s.toolBonus(t.ID),
|
||||||
}
|
|
||||||
|
|
||||||
// drillAt is what a stake of the item at the levels would take and return now.
|
|
||||||
func (s *State) drillAt(item *StoreItem, lv Levels, resource string) (float64, float64) {
|
|
||||||
return drillStats(item.ToolSecs(lv), item.ToolYield(lv), s.Bonus(),
|
|
||||||
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
|
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
|
||||||
}
|
}
|
||||||
|
|
||||||
// sellPrice is what one unit pays the player now, before selling pressure: the shared
|
// sellPrice is what one unit sells for now: market rate × live events × advisors.
|
||||||
// quote × their advisors.
|
|
||||||
func (s *State) sellPrice(name string) float64 {
|
func (s *State) sellPrice(name string) float64 {
|
||||||
return marketQuote(name, s.Now, s.PriceBoosts.For(name)) * s.Bonus().Sell
|
return marketRate(name, s.Now) * s.PriceBoosts.For(name) * s.Bonus().Sell
|
||||||
}
|
}
|
||||||
|
|
||||||
// BonusRow is one active bonus, for display.
|
// BonusRow is one active bonus, for display.
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
build/
|
|
||||||
tools/
|
|
||||||
.internal/
|
|
||||||
*.der
|
|
||||||
.DS_Store
|
|
||||||
dist/
|
|
||||||
__pycache__/
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# AGENTS.md — Moon Miners client (Defold)
|
|
||||||
|
|
||||||
A native client for Moon Miners (rules and state live in the Go server: `../server`,
|
|
||||||
`../server/AGENTS.md`; the repo: `../AGENTS.md`), and the only game client. Layout and HUD
|
|
||||||
look come from the old web client (header, moon list left, the section in the middle, aside with the moon viewport and the
|
|
||||||
selected well; on Moons the aside only shows, every action is on the well cards), made game-native. Target 16:10 at 1280x800 (Steam Deck); bigger windows
|
|
||||||
show the UI at native pixel size with more room, smaller ones rearrange. Mouse now,
|
|
||||||
gamepad later. Details live in each file's header comment; this file has the rules and a
|
|
||||||
map.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
- The server is the authority. Show the state it sends, send actions. Prediction may show
|
|
||||||
an action's result early, computed only from numbers the server sent (`take`, the tool's
|
|
||||||
`cycleSecs`/`yield`, quotes, `rules`; `../server/api.md` "Prediction"); the next response
|
|
||||||
replaces it. No other game rules in Lua. Countdowns tick locally as display only.
|
|
||||||
- Keep changes small; don't touch `../server` from here.
|
|
||||||
- Flex-first: new or reworked UI is laid out with `modules/flex.lua` / `flex_gui.lua`, no
|
|
||||||
hand-placed coordinates (`26 + ui.text_width(name) + 7`). When you touch a hand-placed
|
|
||||||
part, port it. The point is fewer layout bugs and less rework, not fewer lines.
|
|
||||||
- Slot rule: layout owns an outer slot node per box (an `anim.group`, a card's root, a
|
|
||||||
label); animations own what's inside (CSS layout vs transform). Never give a box a node
|
|
||||||
that something animates by position or size. Widgets built at a size (ui.button, bars,
|
|
||||||
dashed edges) are built after the solve at their box's rect; text is cut after it
|
|
||||||
(`ui.fit_text`). Kept blocks aren't re-laid out: solve only what you build.
|
|
||||||
- Clicker rule: buttons never move under the pointer while the player clicks. Nothing above
|
|
||||||
a button changes its position (reserve room: fixed heights, placeholders), and when a
|
|
||||||
thing is used up the next of its kind slides into the same spot (refinery slots, deploy
|
|
||||||
cards), so repeated clicks at one place keep working.
|
|
||||||
- Input focus: the game screen takes input focus once in `init`. Druid never touches it
|
|
||||||
(`[druid] no_auto_input = 1`: a Druid instance's `final()` would release focus for the
|
|
||||||
whole gui script, so closing a dialog left the game deaf). Don't post
|
|
||||||
acquire/release_input_focus anywhere else. The dialog's input invariant
|
|
||||||
(`dialog:check`) logs `ERROR:MM: input invariant broken` when repaired; check.py fails on
|
|
||||||
`ERROR:MM`.
|
|
||||||
- One Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog), never screens.
|
|
||||||
One Druid instance per gui_script (the dialog has its own). Interaction goes through
|
|
||||||
Druid via the components; a raw `druid:new_button` is mouse-only and has no test id.
|
|
||||||
- Every interactive element registers a stable test id (button.bind, tabs.tab, row, field
|
|
||||||
do; ids in `modules/test.lua`'s header). Give new interactive things an id: focus,
|
|
||||||
keys, fx and the test driver depend on it.
|
|
||||||
- Build from templates cloned at runtime (`ui.clone`; new kinds: a template in
|
|
||||||
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
|
|
||||||
layout is top-left based (x right, y down).
|
|
||||||
- Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua;
|
|
||||||
`ASIDE_KEYS` and the header's sig likewise; the moon list's rows: `moonlist.rows_sig`;
|
|
||||||
the moon's facts in the aside: `aside.facts_sig`; the Moons parts: their models in
|
|
||||||
moons.lua) or it won't update. The shown state is frozen (`blocks.freeze`: `blocks.sig`
|
|
||||||
writes each part once per state, by content): never change `g.state` in place; a
|
|
||||||
prediction changes its copy before it is shown. What must survive
|
|
||||||
a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across
|
|
||||||
builds outside its block. A new per-build registry notes its entries in the block journal
|
|
||||||
(`blocks.note`/`push`/`put`); code that builds outside a build runs through
|
|
||||||
`blocks.bound(fn)`.
|
|
||||||
- Theme: every color is a token (`theme.color`, dark and light, from the old web client's CSS): no hex or
|
|
||||||
vector colors in code (templates via `theme.template`, config colors via `theme.item`,
|
|
||||||
glows via `theme.glow_blend()`). The moon viewport stays dark. Sizes from `theme.size`.
|
|
||||||
- Text: styles from `theme.text`; `₥`, `★` and other symbols through the `sym` font
|
|
||||||
(`ui.credits`, `ui.label`). `ui.text` escapes markup unless the style has `markup = true`;
|
|
||||||
server text is safe to pass. Add missing characters to the `.font` (make check names them).
|
|
||||||
- Node budget: `max_nodes: 4096` in `game.gui`; text past the font batch limit silently
|
|
||||||
doesn't render (make check catches the warning); stencils and images split batches.
|
|
||||||
Outline textures use slice9 2.
|
|
||||||
- Motion: easing curves match the old web client (`modules/anim.lua`). GUI nodes don't pass alpha to
|
|
||||||
children: fade content in from under a veil, not by fading a subtree. Every new
|
|
||||||
animation honors reduced motion (`anim.reduced`). Effects for action results go in
|
|
||||||
`modules/fx.lua`; numbers that change ease (`H.tick_num`); the wallet counts through fx.
|
|
||||||
- Scaling: layout size = window / (UI scale × display scale); below 960x600 it scales down;
|
|
||||||
below 1250 wide the aside gets its own row and the page scrolls. Content caps
|
|
||||||
(`theme.size`) are all above what fits at 1280x800; keep it that way (compare shots).
|
|
||||||
- Web: use dt or the clock for motion (variable frame rate); after render changes check the
|
|
||||||
browser console (WebGL feedback loops, Firefox target resizes).
|
|
||||||
- Native code (`mmwindow/`, `mmterrain/`): user rule, C only (`.c`, Lua C API); C++ only
|
|
||||||
where the dmSDK forces it, as a C-style shim (free functions and plain structs: no
|
|
||||||
classes, methods, templates, STL, references, own namespaces, exceptions, RTTI). The
|
|
||||||
extender compiles `.c` as C++, so the C must also be valid C++, with `extern "C"` across
|
|
||||||
files. Changing a native extension (or the dependencies) sends a remote engine build.
|
|
||||||
- `[mm]` in `game.project` holds only the keys in `scripts/check.py` `MM_DEFAULTS` at their
|
|
||||||
shipped values; pass others with `--config=mm.<key>=<value>`. Don't change
|
|
||||||
`display.width/height`.
|
|
||||||
- Never kill processes by name; scripts stop the game by its pid.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
- Use your own server and database: `../scripts/wt up <task>` (see `../AGENTS.md`) prints
|
|
||||||
the `MM_API` to use. Never test against the dev server on :3000, never touch the user's
|
|
||||||
running game or their settings file (`~/.local/share/moonminers/settings`): give any game
|
|
||||||
you start its own `XDG_DATA_HOME` (drive.py does: `<out>/data`).
|
|
||||||
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
|
|
||||||
`scripts/views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you
|
|
||||||
touch it. Add a scenario per feature (`scripts/scenarios/*.py`; each file's docstring
|
|
||||||
says what it checks and needs). Run `make check` (with `MM_API` set to your test
|
|
||||||
server; it makes a fixture player there) before calling it done.
|
|
||||||
- Drive commands (`scripts/drive.py`, through the test hook): `click(id | prefix |
|
|
||||||
"label=<text>")`, `hover`, `scroll_to`, `key`, `type`, `navigate`, `wait_for(lua expr)`,
|
|
||||||
`idle()`, `eval`, `elements()`, `state()`, `set(...)`, `shot(name)`, `log_has`,
|
|
||||||
`expect`, `restart()`, `cmd("frames", "reset")`; real X input: `os_click`, `os_move`,
|
|
||||||
`os_key`, `os_point`. `--attach $(cat dist/dev/port)` drives a `make dev`/`make run` game.
|
|
||||||
- `scripts/fixture.py <api>` makes a player with every section filled (needs an MM_DEV=1
|
|
||||||
server, which `wt up` starts).
|
|
||||||
- Layout lint instead of judging screenshots: `make lint MM_API=...` lints every section at
|
|
||||||
1280x800 and 1920x1080 and checks the clicker rule on a few actions (claim, collect,
|
|
||||||
sell, buy, upgrade); issues in `dist/drive/lint-<size>/lint-summary.json`. One screen:
|
|
||||||
`drive.py [--attach PORT] dump "{section='store'}"`. In a scenario: `game.lint(name)`,
|
|
||||||
`game.click_check(id)`, `game.dump(name)` (the test hook's `dump`: every shown node's
|
|
||||||
rect, kind, test id, text, clip). Rules and output: `scripts/ui_lint.py` header (overlap,
|
|
||||||
overflow, off_screen, small_hit, text_cut; moved, clicked_moved, clicked_gone). Add a
|
|
||||||
click_check to a feature's scenario when it has buttons people click repeatedly.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/`; `build/` is
|
|
||||||
bob's output, `build/x86_64-linux/dmengine` the engine with our extensions.
|
|
||||||
- `make run ARGS="--config=mm.api_base=http://localhost:3101"`: build, then run the game
|
|
||||||
from `build/default` in the foreground (loose files, engine service on), so `make reload`
|
|
||||||
works on it.
|
|
||||||
- `make dev` (same, in the background; `HEADLESS=1` under its own Xvfb), `make reload`,
|
|
||||||
`make dev-stop`: hot reload, below. State in `dist/dev/` (pid, port, display, game.log).
|
|
||||||
- `make shot`, `make play`, `make drive`, `make check`, `make web`, `make serve-web`: see
|
|
||||||
the Makefile comments. Web build test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py`,
|
|
||||||
`node scripts/web-shots.mjs` (Playwright; header of the script).
|
|
||||||
|
|
||||||
## Hot reload (`make reload`, `scripts/dev.py`)
|
|
||||||
`make reload` runs `bob build` (no bundle, a few seconds) and posts every changed file in
|
|
||||||
`build/default` to the running debug game's resource reload (`/post/@resource/reload`, as
|
|
||||||
the editor does); the game's gui script runs again and rebuilds, keeping its state (player,
|
|
||||||
view, scroll offsets). It fails loudly on a compile error and keeps the old code running.
|
|
||||||
- Reloads: our Lua modules (`modules/`, `screens/`, `widgets/`, `components/`) and the
|
|
||||||
game's `.gui_script` (tested). Other changed resources (`.gui`, atlases, fonts) are sent
|
|
||||||
too; restart if one looks wrong.
|
|
||||||
- Needs a restart (reload says so; `make dev-stop dev`, or quit and `make run`):
|
|
||||||
collections, game objects, `main.script`, materials, shaders, the render script; modules
|
|
||||||
that hold state shared with other scripts (`modules.moon.*`, `modules.api`,
|
|
||||||
`modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`); `game.project`;
|
|
||||||
native extensions and dependencies (a new engine).
|
|
||||||
- New fields that `init` sets on `g` don't exist after a reload: make them in `ensure`
|
|
||||||
(core/main.lua) or lazily.
|
|
||||||
|
|
||||||
## Map
|
|
||||||
Game screen (`screens/game/`); `game.gui_script` forwards to `core/main.lua`. Module
|
|
||||||
contract: `M.build(g, root, x, y, w, h, H)`, optional `M.aside`, `M.aside_below`,
|
|
||||||
`M.update(g, dt)`, `M.teardown(g)` (header of core/main.lua). A new section: a module here,
|
|
||||||
an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
|
|
||||||
- `core/main.lua`: lifecycle, rebuilds, build orchestration, entrances, `on_reload`.
|
|
||||||
- `core/session.lua`: state, server requests (one at a time), prediction hookup, history.
|
|
||||||
- `core/hooks.lua`: the `H` helpers (`action`, `link`, `claim_wait`, text blocks).
|
|
||||||
- `core/keys.lua`: keyboard (focus, Enter/Space, scrolling keys, fields, F11, Ctrl +/-/0).
|
|
||||||
- `core/ready.lua`: ready counts on our clock. `core/ticks.lua`: countdowns, bars, eased numbers.
|
|
||||||
- Regions: `banner.lua`, `header.lua`, `moonlist.lua`, `aside.lua`, `settings.lua` (gear
|
|
||||||
menu), `savecode.lua`, `grant.lua` (admin grant dialog), `admin.lua` (admin area from the gear
|
|
||||||
menu, instead of the page; screens: `moonlab.lua`, the moon settings editor).
|
|
||||||
- Sections: `moons.lua`, `market.lua` (+ `ticker.lua`, `chart.lua`, `sale.lua`,
|
|
||||||
`leaderboard.lua`), `refinery.lua`, `equipment.lua`, `store.lua`, `advisors.lua`,
|
|
||||||
`help.lua`, `moon_sale.lua`, `reveal.lua` (new-moon teaser and reveal window), `cards.lua`
|
|
||||||
(shared by card sections).
|
|
||||||
- Scripted runs: `autoplay.lua`, `autoplay_refinery.lua`, `predict_demo.lua`.
|
|
||||||
|
|
||||||
Components (`components/`, API in each header; build new UI from these): `button`,
|
|
||||||
`dialog`, `panel`, `card` (+ `card.grid`), `tabs`, `list` (long lists, Druid Data List),
|
|
||||||
`scroll`, `bar`, `chip`, `stat`, `row`, `slider`. `widgets/moon_row.lua`: the moon list row.
|
|
||||||
|
|
||||||
Modules (`modules/`):
|
|
||||||
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
|
|
||||||
(applies it to nodes, tree builders), `blocks.lua` (kept blocks, journal). For text and
|
|
||||||
parts that come and go, use flex_gui's guards: `fg.reserve(style, kid_or_nil)` (a fixed
|
|
||||||
slot whether its content shows: a "Need ₥ more" line, a count, a note), `fg.label(...,
|
|
||||||
{ fit = true, shrink_text = 0.8 })` (one line: shrink, then cut with …), `fg.para(...,
|
|
||||||
{ lines = 2 })` (at most n lines, cut with …).
|
|
||||||
- `ui.lua` (clone, text, rects, markup), `theme.lua`, `anim.lua`, `fx.lua`, `sfx.lua`,
|
|
||||||
`toasts.lua`, `tooltip.lua`, `focus.lua`, `field.lua`, `screen.lua` (scaling, fullscreen),
|
|
||||||
`settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
|
|
||||||
- `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
|
|
||||||
`hot.lua` (hot reload).
|
|
||||||
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, the moon settings and
|
|
||||||
their `DEFAULTS`, the look the server's `moondesc.go` mirrors).
|
|
||||||
|
|
||||||
Elsewhere:
|
|
||||||
- `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
|
|
||||||
- `moon/`: moon collection, `moon.script`, materials (port of scene.js).
|
|
||||||
`render/mm.render_script`: HDR moon pass with bloom.
|
|
||||||
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator, the
|
|
||||||
game's only one (it began as a C port of the web client's terrain.js, now deleted).
|
|
||||||
- `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
|
|
||||||
`ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
|
|
||||||
- `web/index.html`: the web build's page.
|
|
||||||
- `scripts/`: `dev.py` (run/hot reload), `drive.py` + `scenarios/`, `check.py`,
|
|
||||||
`fixture.py`, `shot.sh`, `views.sh`, `serve-web.py`, `web-shots.mjs`, `errproxy.py`.
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
|
|
||||||
`6823d775883a04f4210bf04cd54cbffc468ac0ad`, `tools/bob.jar` of the same sha). Dependencies
|
|
||||||
(pinned in `game.project`): Druid 1.4.1, Monarch 6.0.3, Defold Event 21 (native, so the
|
|
||||||
engine comes from Defold's build server; bob caches it in `.internal/cache` until the
|
|
||||||
native inputs change). Druid and Defold GUI findings: `../../druid-notes.md`. Sources
|
|
||||||
(read-only): `~/Development/Refold/druid`, `~/Development/Refold/Defold`.
|
|
||||||
|
|
||||||
## Config (`--config=mm.<key>=<value>`)
|
|
||||||
`api_base` (server; default `http://localhost:3000`, on the web the page's origin; player id
|
|
||||||
saved per server), `player`, `new_player=1`, `view` (`section=equipment&drill=<id>`),
|
|
||||||
`window=WxH`, `ui_scale`, `display_scale`, `theme`, `reduced_motion`, `moon_id`,
|
|
||||||
`moon_spin=0`, `autoplay`, `autoplay_script`, `quit_after`, `popup`, `test=0`; debug:
|
|
||||||
`fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`, `tween_ms`, `predict_demo`,
|
|
||||||
`reveal_freeze`, `sfx_debug`.
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Flex layout: notes from the first port
|
|
||||||
|
|
||||||
Solver `modules/flex.lua` (430 lines incl. docs), apply helper `modules/flex_gui.lua` (96),
|
|
||||||
tests `scripts/flex_test.lua` (53 checks, in `make check`).
|
|
||||||
|
|
||||||
## Lines, before -> after
|
|
||||||
|
|
||||||
| File | Lines | Hand-placed offsets (`x + `, `y + `, `+ 8`, `set_position`) |
|
|
||||||
|---|---|---|
|
|
||||||
| `screens/game/refinery.lua` | 524 -> 595 | 35 -> 10 |
|
|
||||||
| `components/card.lua` | 298 -> 325 | Druid Layout gone; one tree replaces three branches of padding math |
|
|
||||||
| `screens/game/header.lua` (wallet only) | 243 -> 256 | running `cx` cursor gone |
|
|
||||||
|
|
||||||
The code is longer, not shorter: every text is now a box with its line-box height, and
|
|
||||||
the tree literals add structure lines. What it gains: no running cursors, no "measure, then
|
|
||||||
come back and move it" (`gui.set_position(mx, vec(w - mw, -fy))`, the slot label's probe
|
|
||||||
node, the Collect all offset `67.9 - 44`). Spacing is in one place per container, and the
|
|
||||||
heading and stock now grow with their content (before: fixed 67.9 px, chips wrapped by
|
|
||||||
hand).
|
|
||||||
|
|
||||||
Screenshots (`shots/c-flex/before|after`, 1280x800 dark and light, 1024x640): Refinery
|
|
||||||
(ready, idle, running slots, buy slot, recipe footers), Store, Equipment, Advisors and the
|
|
||||||
header match pixel for pixel. The only differences are animations caught at another
|
|
||||||
moment (Claim all, Ready glow, pulsing buttons) and fixture data. Scenarios smoke, cards,
|
|
||||||
keys and finalcheck pass (keys timed out once on the Market tab, then passed; the baseline
|
|
||||||
passes too).
|
|
||||||
|
|
||||||
## Easier
|
|
||||||
|
|
||||||
- Rows with something pinned right (time left, "have" counts, Max button), centered
|
|
||||||
labels, and footers pinned to the bottom: `justify`, a `grow = 1` spacer.
|
|
||||||
- Wrapping chips and equal-height slot rows: `wrap = true` plus the default stretch.
|
|
||||||
- Text cut to the space left: a shrinking leaf, then `ui.fit_text(node, box.w)`.
|
|
||||||
|
|
||||||
## Harder
|
|
||||||
|
|
||||||
- Matching the old pixel offsets exactly. The old code centers text at hand-picked middles
|
|
||||||
(12 for one label, 11 for the one beside it), so some rows need leaves of different
|
|
||||||
heights inside a fixed-height row. A new screen built with flex from the start won't
|
|
||||||
have this.
|
|
||||||
- Widgets built at a size (ui.button, the cell bar, dashed edges, the quantity field):
|
|
||||||
solve with an empty box, then build at its rect. Fine, but it splits a component in two.
|
|
||||||
- Creation order is focus and Druid input order, so nodes must still be built in the old
|
|
||||||
order; only their placement moves to the end.
|
|
||||||
- bob follows `require` strings, and flex.apply -> flex_gui -> flex is a cycle; it uses a
|
|
||||||
non-literal name.
|
|
||||||
|
|
||||||
## What the solver does that CSS doesn't (or does differently)
|
|
||||||
|
|
||||||
- border-box sizes, `min-width: 0` by default (CSS's auto min size would stop labels from
|
|
||||||
being cut), auto size = fit-content even at the root, align-content always start, no
|
|
||||||
margins (spacer boxes), no baseline alignment.
|
|
||||||
- No pixel snapping. One label (buy slot) is solved, then placed on a whole pixel by hand,
|
|
||||||
as the old code did; a `round` option could do it.
|
|
||||||
- CSS grid's `minmax(200px, 1fr)` isn't flex: the columns are computed (as before) and
|
|
||||||
given to wrapped items as widths. A small `grid` helper would cover card.grid and the
|
|
||||||
slots.
|
|
||||||
|
|
||||||
## Also fixed
|
|
||||||
|
|
||||||
- `card.grid` rebuilt a kept card whose row height changed without the card options
|
|
||||||
(a recipe card would have gotten an image column).
|
|
||||||
|
|
||||||
## Recommendation for the rest
|
|
||||||
|
|
||||||
Port section by section when a section is touched anyway, not as one sweep: equipment's
|
|
||||||
tool cards and the drill panel, store and advisors (already on Card), market's ticket and
|
|
||||||
stats, the header's tab row (it has a "second row when it doesn't fit" rule, which is a
|
|
||||||
wrap). Keep kept blocks as they are: solve what a build makes. Leave lists (Data List)
|
|
||||||
and the scroll areas alone. Add a `grid` helper and rounding before the next big port.
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# Moon Miners Defold client. Builds with bob.jar, run by the Java the Defold editor ships
|
|
||||||
# (its packages/jdk-*), so the editor is all a build needs. The editor is looked for where it
|
|
||||||
# usually is on Linux (~/Defold, /opt/Defold), macOS (Defold.app in /Applications or
|
|
||||||
# ~/Applications) and Windows (~/Defold, C:/Defold, Git Bash or MSYS make); DEFOLD=<the folder
|
|
||||||
# holding its packages> for another place, JAVA=<path> for a Java of your own. With no editor
|
|
||||||
# found it is java (21+) from PATH. xvfb-run, Xvfb, ImageMagick, xdotool, python3 and luajit
|
|
||||||
# for shot, play, drive, dev and check (Linux).
|
|
||||||
DEFOLD_DIRS := $(DEFOLD) $(HOME)/Defold /opt/Defold \
|
|
||||||
/Applications/Defold.app/Contents/Resources $(HOME)/Applications/Defold.app/Contents/Resources \
|
|
||||||
$(USERPROFILE)/Defold C:/Defold
|
|
||||||
# (make's own wildcard and sort, no shell tools; the last after sorting is the newest)
|
|
||||||
DEFOLD_JAVA := $(lastword $(sort $(foreach d,$(DEFOLD_DIRS),$(wildcard \
|
|
||||||
$(d)/packages/jdk*/bin/java $(d)/packages/jdk*/bin/java.exe $(d)/packages/jdk*/Contents/Home/bin/java))))
|
|
||||||
JAVA ?= $(or $(DEFOLD_JAVA),java)
|
|
||||||
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
|
|
||||||
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
|
|
||||||
BOB := tools/bob.jar
|
|
||||||
PLATFORM := x86_64-linux
|
|
||||||
VARIANT ?= debug
|
|
||||||
BUNDLE := dist/$(PLATFORM)/MoonMiners
|
|
||||||
BIN := $(BUNDLE)/MoonMiners.x86_64
|
|
||||||
SHOT ?= dist/shot.png
|
|
||||||
# Extra engine args, e.g. ARGS="--config=mm.api_base=http://localhost:3001"
|
|
||||||
ARGS ?=
|
|
||||||
|
|
||||||
.PHONY: build run shot play clean resolve dev reload dev-stop drive lint check web serve-web
|
|
||||||
|
|
||||||
$(BOB):
|
|
||||||
mkdir -p tools
|
|
||||||
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
|
|
||||||
|
|
||||||
resolve: $(BOB)
|
|
||||||
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
|
|
||||||
|
|
||||||
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
|
|
||||||
.internal/lib/dependencies.json: game.project | $(BOB)
|
|
||||||
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
|
|
||||||
touch $@
|
|
||||||
|
|
||||||
build: $(BOB) .internal/lib/dependencies.json
|
|
||||||
$(JAVA) -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
|
|
||||||
--archive --bundle-output dist/$(PLATFORM) build bundle
|
|
||||||
|
|
||||||
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release
|
|
||||||
# by default; WEB_VARIANT=debug keeps logs and the test hook (window.mmTestQueue).
|
|
||||||
# The game server serves it at / (server.go, MM_WEB_DIR); `make serve-web` serves it on :8080
|
|
||||||
# with /api and /static proxied to UPSTREAM (a test server).
|
|
||||||
WEB_VARIANT ?= release
|
|
||||||
UPSTREAM ?= http://localhost:3000
|
|
||||||
WEB_PORT ?= 8080
|
|
||||||
web: $(BOB) .internal/lib/dependencies.json
|
|
||||||
$(JAVA) -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
|
|
||||||
--archive --bundle-output dist/web build bundle
|
|
||||||
|
|
||||||
serve-web:
|
|
||||||
scripts/serve-web.py --port $(WEB_PORT) --upstream $(UPSTREAM)
|
|
||||||
|
|
||||||
# Hot reload loop: `make dev` starts the game from build/default (once), `make reload`
|
|
||||||
# rebuilds (no bundle) and posts what changed to the running engine. HEADLESS=1 make dev
|
|
||||||
# runs it under Xvfb. See AGENTS.md "Fast iteration".
|
|
||||||
dev: build
|
|
||||||
scripts/dev.py start $(ARGS)
|
|
||||||
|
|
||||||
reload: $(BOB)
|
|
||||||
@mkdir -p dist/dev
|
|
||||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|
|
||||||
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
|
|
||||||
@scripts/dev.py reload
|
|
||||||
|
|
||||||
dev-stop:
|
|
||||||
scripts/dev.py stop
|
|
||||||
|
|
||||||
# A scenario through the test hook (scripts/drive.py, AGENTS.md "Test driver") on a fresh
|
|
||||||
# build/default, e.g. make drive SCENARIO=smoke MM_API=http://localhost:3118 DRIVE=--fixture
|
|
||||||
SCENARIO ?= smoke
|
|
||||||
DRIVE ?= --fixture
|
|
||||||
drive: $(BOB) .internal/lib/dependencies.json
|
|
||||||
@mkdir -p dist
|
|
||||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
|
||||||
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
|
||||||
scripts/drive.py $(DRIVE) $(SCENARIO)
|
|
||||||
|
|
||||||
# Layout lint of every section at 1280x800 and 1920x1080 (scenarios/lint.py, ui_lint.py):
|
|
||||||
# dist/drive/lint-<size>/. Needs MM_API (a test server).
|
|
||||||
lint: $(BOB) .internal/lib/dependencies.json
|
|
||||||
@mkdir -p dist
|
|
||||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
|
||||||
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
|
||||||
@for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done
|
|
||||||
|
|
||||||
# Checks (scripts/check.py): Lua syntax, debug flags, font coverage, and the log of a
|
|
||||||
# scenario run (sections) against MM_API (required: a test server) if it answers.
|
|
||||||
check: $(BOB) .internal/lib/dependencies.json
|
|
||||||
@mkdir -p dist
|
|
||||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|
|
||||||
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
|
|
||||||
scripts/check.py
|
|
||||||
|
|
||||||
# The game from build/default (loose files, engine service on), so `make reload` works on it.
|
|
||||||
# The bundle in dist/ is for shot, play and shipping.
|
|
||||||
run: build
|
|
||||||
scripts/dev.py run $(ARGS)
|
|
||||||
|
|
||||||
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
|
|
||||||
shot: build
|
|
||||||
scripts/shot.sh $(BIN) $(SHOT) $(if $(MM_API),--config=mm.api_base=$(MM_API)) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
|
||||||
|
|
||||||
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
|
|
||||||
# Needs MM_API (a test server): it makes a new player there.
|
|
||||||
play: build
|
|
||||||
@test -n "$(MM_API)" || { echo "make play: set MM_API to a test server (no default)"; exit 1; }
|
|
||||||
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.api_base=$(MM_API) --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -rf build dist .internal/cache
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION AND CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
Copyright 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium)
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
https://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION & CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
Copyright (c) 2014, Indian Type Foundry (info@indiantypefoundry.com).
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION & CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
font: "/assets/fonts/Oxanium-Medium.ttf"
|
|
||||||
material: "/builtins/fonts/font-df.material"
|
|
||||||
size: 40
|
|
||||||
outline_alpha: 0.0
|
|
||||||
outline_width: 0.0
|
|
||||||
shadow_alpha: 0.0
|
|
||||||
output_format: TYPE_DISTANCE_FIELD
|
|
||||||
render_mode: MODE_SINGLE_LAYER
|
|
||||||
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
font: "/assets/fonts/Oxanium-SemiBold.ttf"
|
|
||||||
material: "/builtins/fonts/font-df.material"
|
|
||||||
size: 40
|
|
||||||
outline_alpha: 0.0
|
|
||||||
outline_width: 0.0
|
|
||||||
shadow_alpha: 0.0
|
|
||||||
output_format: TYPE_DISTANCE_FIELD
|
|
||||||
render_mode: MODE_SINGLE_LAYER
|
|
||||||
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
font: "/assets/fonts/Rajdhani-Medium.ttf"
|
|
||||||
material: "/builtins/fonts/font-df.material"
|
|
||||||
size: 40
|
|
||||||
outline_alpha: 0.0
|
|
||||||
outline_width: 0.0
|
|
||||||
shadow_alpha: 0.0
|
|
||||||
output_format: TYPE_DISTANCE_FIELD
|
|
||||||
render_mode: MODE_SINGLE_LAYER
|
|
||||||
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
font: "/assets/fonts/Rajdhani-Bold.ttf"
|
|
||||||
material: "/builtins/fonts/font-df.material"
|
|
||||||
size: 40
|
|
||||||
outline_alpha: 0.0
|
|
||||||
outline_width: 0.0
|
|
||||||
shadow_alpha: 0.0
|
|
||||||
output_format: TYPE_DISTANCE_FIELD
|
|
||||||
render_mode: MODE_SINGLE_LAYER
|
|
||||||
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
font: "/assets/fonts/Rajdhani-SemiBold.ttf"
|
|
||||||
material: "/builtins/fonts/font-df.material"
|
|
||||||
size: 40
|
|
||||||
outline_alpha: 0.0
|
|
||||||
outline_width: 0.0
|
|
||||||
shadow_alpha: 0.0
|
|
||||||
output_format: TYPE_DISTANCE_FIELD
|
|
||||||
render_mode: MODE_SINGLE_LAYER
|
|
||||||
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
font: "/assets/fonts/Inter-SemiBold.ttf"
|
|
||||||
material: "/builtins/fonts/font-df.material"
|
|
||||||
size: 40
|
|
||||||
outline_alpha: 0.0
|
|
||||||
outline_width: 0.0
|
|
||||||
shadow_alpha: 0.0
|
|
||||||
output_format: TYPE_DISTANCE_FIELD
|
|
||||||
render_mode: MODE_SINGLE_LAYER
|
|
||||||
characters: "\342\211\210 !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\202\245\342\230\205\342\230\206\342\227\206\342\226\262\342\226\274\342\200\272\342\227\217\342\227\257\316\221\316\222\316\223\316\224\316\225\316\226\316\227\316\230\316\231\316\232\316\233\316\234\316\235\316\236\316\237\316\240\316\241\316\243\316\244\316\245\316\246\316\247\316\250\316\251\316\261\316\262\316\263\316\264\316\265\316\266\316\267\316\270\316\271\316\272\316\273\316\274\316\275\316\276\316\277\317\200\317\201\317\202\317\203\317\204\317\205\317\206\317\207\317\210\317\211"
|
|
||||||
|
Before Width: | Height: | Size: 111 B |
|
Before Width: | Height: | Size: 394 B |
|
Before Width: | Height: | Size: 877 B |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1013 B |
|
Before Width: | Height: | Size: 330 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 806 B |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 234 B |
|
Before Width: | Height: | Size: 712 B |
|
Before Width: | Height: | Size: 703 B |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 988 B |
|
Before Width: | Height: | Size: 310 B |
@@ -1,34 +0,0 @@
|
|||||||
Moon Miners sound effects (Defold client)
|
|
||||||
|
|
||||||
Clips in this folder are built (trimmed, pitched down, filtered, layered with synth tones
|
|
||||||
made in SoX, loudness-matched, re-encoded) from Kenney's audio packs, www.kenney.nl.
|
|
||||||
License: Creative Commons Zero (CC0), http://creativecommons.org/publicdomain/zero/1.0/
|
|
||||||
(credit not required, given anyway).
|
|
||||||
|
|
||||||
nav.wav Interface Sounds switch_007
|
|
||||||
toggle.wav Interface Sounds switch_005
|
|
||||||
open.wav Sci-Fi Sounds doorOpen_001 + synth servo tone
|
|
||||||
close.wav Sci-Fi Sounds doorClose_001 + synth servo tone
|
|
||||||
buy.ogg Sci-Fi Sounds impactMetal_000 + Impact Sounds impactMetal_medium_001
|
|
||||||
+ synth beep
|
|
||||||
upgrade.ogg Sci-Fi Sounds forceField_000 + Impact Sounds impactMetal_heavy_002
|
|
||||||
+ synth sweep
|
|
||||||
moon.ogg Sci-Fi Sounds lowFrequency_explosion_001 + forceField_003 + synth ping
|
|
||||||
deploy.ogg Impact Sounds impactMining_004 + Sci-Fi Sounds engineCircular_002
|
|
||||||
refine.ogg Sci-Fi Sounds forceField_001 + computerNoise_000
|
|
||||||
collect.ogg Sci-Fi Sounds doorOpen_002 + impactMetal_004 + synth beeps
|
|
||||||
|
|
||||||
modal.wav, confirm.ogg, error.wav and ready.wav are synthesized with SoX (no samples).
|
|
||||||
|
|
||||||
Packs: https://kenney.nl/assets/interface-sounds, https://kenney.nl/assets/sci-fi-sounds,
|
|
||||||
https://kenney.nl/assets/impact-sounds
|
|
||||||
|
|
||||||
The claim whir, counter ticks, claim blips, credit blips and the moon reveal sounds
|
|
||||||
(harvest.wav, tick.wav, arrive.wav, coin.wav, charge.wav, burst.wav, powerdown.wav,
|
|
||||||
shimmer.wav) are synthesized with ZzFX by Frank Force (MIT, LICENSE-ZzFX.txt),
|
|
||||||
https://github.com/KilledByAPixel/ZzFX, pre-rendered by scripts/zzfx-render.mjs with the
|
|
||||||
web build's parameters (server/web/static/js/sfx.js). rumble.wav and reveal.wav (the new-moon
|
|
||||||
reveal's build-up and burst, client only) are synthesized by the same script.
|
|
||||||
|
|
||||||
Copied from the web build (server/web/static/assets/sfx); the .mp3 clips were re-encoded
|
|
||||||
to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2019 Frank Force
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
images {
|
|
||||||
image: "/assets/images/white.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/frame.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/outline.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/sphere.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/glow.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/fade.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/ring.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/speaker.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/speaker_off.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/spark.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/sun.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/gear.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/moon.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/pentagon.png"
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
image: "/assets/images/pentagon_rim.png"
|
|
||||||
}
|
|
||||||
margin: 2
|
|
||||||
extrude_borders: 2
|
|
||||||
inner_padding: 0
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
-- Progress and timer bars (the web's .drill-bar / .bar): a track and a fill. A timer bar
|
|
||||||
-- fills from start to end (ms, server clock) as display only, easing corrections (core
|
|
||||||
-- ticks, keyed); once full it takes the ready look: a soft glow in its color that breathes
|
|
||||||
-- (app.css .drill-bar.is-ready). A static bar shows a fraction.
|
|
||||||
--
|
|
||||||
-- bar.timer(g, parent, x, y, w, h, { start, ends, ready, color, none, key })
|
|
||||||
-- bar.value(parent, x, y, w, h, fraction, { color, track })
|
|
||||||
-- bar.ready_label(parent, text, x, y, pivot) -- "READY" in gold, its glow pulsing
|
|
||||||
-- -- (app.css .ready, 1.8 s)
|
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
local anim = require("modules.anim")
|
|
||||||
local ticks = require("screens.game.core.ticks")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
local function vec(x, y)
|
|
||||||
return vmath.vector3(x, y, 0)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The ready look on a fill: full, in its own color (cyan, amber when short), with a glow
|
|
||||||
-- under it that breathes (app.css .drill-bar.is-ready). A drill that gets nothing has no
|
|
||||||
-- glow (.is-none.is-ready).
|
|
||||||
local function ready_look(parent, fill, x, y, w, h, color, none)
|
|
||||||
if none then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local glow = ui.rect(parent, x - 4, y - 4, w + 8, h + 8, color, 0.25)
|
|
||||||
gui.play_flipbook(glow, "glow")
|
|
||||||
gui.move_below(glow, fill)
|
|
||||||
anim.breathe(glow, "color.w", 0.35, 0.12, 1.8)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A timer bar. opts: start, ends (ms), ready (already done: full, glowing), color (default
|
|
||||||
-- "cyan"), track (default "line"), none (the drill gets nothing: an amber dashed track, a
|
|
||||||
-- muted fill, no glow; app.css .drill-bar.is-none), key (eases corrections; core ticks).
|
|
||||||
-- Returns the fill node.
|
|
||||||
function M.timer(g, parent, x, y, w, h, opts)
|
|
||||||
local color = opts.color or "cyan"
|
|
||||||
if opts.none then
|
|
||||||
color = "muted"
|
|
||||||
for dx = 0, w - 1, 4 do
|
|
||||||
ui.rect(parent, x + dx, y, math.min(2, w - dx), h, "ready", 0.45)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
ui.rect(parent, x, y, w, h, opts.track or color, opts.track and 1 or 0.16)
|
|
||||||
end
|
|
||||||
local fill = ui.rect(parent, x, y, w, h, color, opts.none and 0.7 or 1)
|
|
||||||
if opts.ready then
|
|
||||||
ticks.tick_bar(g, fill, 0, 0, w, opts.key)
|
|
||||||
ready_look(parent, fill, x, y, w, h, color, opts.none)
|
|
||||||
else
|
|
||||||
ticks.tick_bar(g, fill, opts.start, opts.ends, w, opts.key, function()
|
|
||||||
-- Full at the local zero (display only; the server says when it's claimable).
|
|
||||||
ready_look(parent, fill, x, y, w, h, color, opts.none)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
return fill
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A static bar showing `fraction` (0..1). opts: color, track, min (smallest fill width).
|
|
||||||
function M.value(parent, x, y, w, h, fraction, opts)
|
|
||||||
opts = opts or {}
|
|
||||||
ui.rect(parent, x, y, w, h, opts.track or "line")
|
|
||||||
local fw = math.floor(w * math.max(0, math.min(1, fraction)))
|
|
||||||
if fw > 0 then
|
|
||||||
return ui.rect(parent, x, y, math.max(opts.min or 0, fw), h, opts.color or "cyan")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- "Ready" in gold whose glow pulses (a soft glow sprite behind it, breathing like the
|
|
||||||
-- web's text-shadow). x, y, pivot like ui.text.
|
|
||||||
function M.ready_label(parent, text, x, y, pivot, px)
|
|
||||||
local style = { font = "sans_semi", px = px or 13, color = "ready", tracking = 0.1, upper = true }
|
|
||||||
local t = ui.text(parent, text or "Ready", style, x, y, pivot)
|
|
||||||
local w = ui.text_width(t)
|
|
||||||
local left = (pivot == gui.PIVOT_E and x - w) or (pivot == gui.PIVOT_CENTER and x - w / 2) or x
|
|
||||||
local glow = ui.rect(parent, left - 12, y - 11, w + 24, 22, "ready", 0)
|
|
||||||
gui.play_flipbook(glow, "glow")
|
|
||||||
gui.move_below(glow, t)
|
|
||||||
anim.breathe(glow, "color.w", 0, 0.3, 1.8)
|
|
||||||
return t
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
-- Button (the web's .btn): a filled box with an edge and an uppercase label, optional
|
|
||||||
-- price ("· ₥1,234"). Variants: "secondary" (default, .btn), "primary" (.btn-primary),
|
|
||||||
-- "redrill" (.btn-redrill, violet), "danger" (.btn-danger), "ghost" (.btn-dashed: faint,
|
|
||||||
-- for "+ New moon"; opts.on lights it). States, eased like a CSS transition (120 ms):
|
|
||||||
-- hover (mouse over), press (held down), disabled (dimmed, no clicks), busy (the web's
|
|
||||||
-- aria-busy: dimmed, label hidden, a spinner; set by core/hooks while an unpredicted
|
|
||||||
-- action waits for the server).
|
|
||||||
--
|
|
||||||
-- local b = button.new(g, parent, "Sell", x, y, { variant = "danger", small = true,
|
|
||||||
-- on_click = fn, id = "sell-moon" }) -- draw + behavior; b.root, b.w, b.h
|
|
||||||
-- button.draw(parent, label, x, y, opts) -- just the nodes (root, nodes, w)
|
|
||||||
-- button.bind(g, root, nodes, opts) -- behavior on drawn nodes (what
|
|
||||||
-- -- H.action and H.link use)
|
|
||||||
--
|
|
||||||
-- opts (draw): variant, small, w, h, price, accent (the price's color), disabled, on;
|
|
||||||
-- fill / edge / text ({ color, alpha } or a color name) override the variant's colors
|
|
||||||
-- (hover then brightens the given fill).
|
|
||||||
-- opts (bind): on_click, id (test id, modules/test), label (for the test hook; default
|
|
||||||
-- the label text), zone (stencil node clipping it, when not under a scroll's view), druid
|
|
||||||
-- (another Druid instance than g.druid: the dialog's).
|
|
||||||
-- After a rebuild the button under the mouse shows its hover at once (core/main replays
|
|
||||||
-- the last pointer position; M.instant makes that skip the easing). A press that a rebuild
|
|
||||||
-- interrupts carries over, for every Druid button with a test id: the one with the same
|
|
||||||
-- id under the pointer is pressed in the new build, so the release still clicks it
|
|
||||||
-- (M.carry). opts.hit: a node to take the clicks instead of root (a Claim button popping
|
|
||||||
-- in is scaled down: core/hooks).
|
|
||||||
|
|
||||||
local theme = require("modules.theme")
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
local anim = require("modules.anim")
|
|
||||||
local test = require("modules.test")
|
|
||||||
local druid_button = require("druid.base.button")
|
|
||||||
|
|
||||||
local S = theme.size
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
local FADE = 0.12
|
|
||||||
|
|
||||||
-- What ui.button drew, by root node (variant, disabled, parts).
|
|
||||||
local looks = ui.button_looks
|
|
||||||
|
|
||||||
local function vec(x, y)
|
|
||||||
return vmath.vector3(x, y, 0)
|
|
||||||
end
|
|
||||||
|
|
||||||
local col = ui.color_spec
|
|
||||||
|
|
||||||
-- Ease (or set, when M.instant or reduced motion) a node's color.
|
|
||||||
local function tint(node, c, instant)
|
|
||||||
gui.cancel_animations(node, "color")
|
|
||||||
if instant or M.instant or anim.reduced then
|
|
||||||
gui.set_color(node, c)
|
|
||||||
else
|
|
||||||
gui.animate(node, "color", c, gui.EASING_OUTQUAD, FADE)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Draw a button (ui.button). Returns root, nodes, width.
|
|
||||||
M.draw = ui.button
|
|
||||||
|
|
||||||
-- The state look: "normal", "hover", "press".
|
|
||||||
local function show(root, state, instant)
|
|
||||||
local look = looks[root]
|
|
||||||
if not look or look.disabled or look.busy then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local function c_of(x)
|
|
||||||
return type(x) == "userdata" and x or col(x)
|
|
||||||
end
|
|
||||||
local c = state == "press" and c_of(look.v.press) or (state == "hover" and c_of(look.v.hover) or look.fill)
|
|
||||||
tint(root, c, instant)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Behavior on a drawn button (or any node: then only clicks, no looks). Returns the Druid
|
|
||||||
-- button.
|
|
||||||
function M.bind(g, root, nodes, opts)
|
|
||||||
opts = opts or {}
|
|
||||||
local look = looks[root]
|
|
||||||
local b = (opts.druid or g.druid):new_button(opts.hit or root, function()
|
|
||||||
if opts.on_click and not (look and look.busy) then
|
|
||||||
opts.on_click()
|
|
||||||
end
|
|
||||||
end):set_animations_disabled()
|
|
||||||
if opts.hit then
|
|
||||||
test.name(opts.hit, opts.id)
|
|
||||||
end
|
|
||||||
if opts.zone then
|
|
||||||
b:set_click_zone(opts.zone)
|
|
||||||
end
|
|
||||||
if look and not look.disabled then
|
|
||||||
b.hover.on_mouse_hover:subscribe(function(_, on)
|
|
||||||
look.hovered = on
|
|
||||||
show(root, on and "hover" or "normal")
|
|
||||||
end)
|
|
||||||
b.hover.on_hover:subscribe(function(_, down)
|
|
||||||
show(root, down and "press" or (look.hovered and "hover" or "normal"), down)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
local label = opts.label
|
|
||||||
if not label and look then
|
|
||||||
label = gui.get_text(look.text)
|
|
||||||
end
|
|
||||||
test.register(opts.id, root, opts.kind or (look and "button" or "link"), label, opts.zone)
|
|
||||||
return b
|
|
||||||
end
|
|
||||||
|
|
||||||
---------------------------------------------------------------- a press across a rebuild
|
|
||||||
|
|
||||||
-- Every Druid button (these, tabs, rows, the moon list's widgets, raw ones), wherever it
|
|
||||||
-- is made: the ones made since the build began, and the one the mouse went down on, by
|
|
||||||
-- its test id (modules/test id_of). A rebuild drops the pressed Druid button, so the
|
|
||||||
-- release would click nothing (the web's element survives an htmx morph); M.carry
|
|
||||||
-- presses its successor instead.
|
|
||||||
local carry = { made = {}, pressed = nil }
|
|
||||||
|
|
||||||
druid_button.mm_hook = function(b)
|
|
||||||
carry.made[#carry.made + 1] = b
|
|
||||||
b.on_pressed:subscribe(function()
|
|
||||||
carry.pressed = { node = b.node, t = socket.gettime() }
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
if not druid_button.mm_init then -- (once: the class outlives a hot reload of this file)
|
|
||||||
druid_button.mm_init = druid_button.init
|
|
||||||
druid_button.init = function(self, ...)
|
|
||||||
druid_button.mm_init(self, ...)
|
|
||||||
if druid_button.mm_hook then
|
|
||||||
druid_button.mm_hook(self)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A build begins (core/main): buttons made from now on are its.
|
|
||||||
function M.begin_build()
|
|
||||||
if carry.pressed and not carry.pressed.id then
|
|
||||||
carry.pressed.id = test.id_of(carry.pressed.node) -- (before the old nodes go)
|
|
||||||
end
|
|
||||||
carry.made = {}
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The mouse went down (before Druid sees it) or up: no press to carry.
|
|
||||||
function M.release()
|
|
||||||
carry.pressed = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
-- before: press the new button(s) with its id, so the release clicks it (Druid checks the pointer then).
|
|
||||||
-- before: press the new button with its id under the pointer, so the release clicks it.
|
|
||||||
function M.carry(x, y)
|
|
||||||
local p = carry.pressed
|
|
||||||
if not p or not p.id then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
-- (not only one under the pointer now: a full build plays the page's entrance, so the
|
|
||||||
-- new button may be elsewhere this frame; Druid checks the pointer at the release)
|
|
||||||
local found
|
|
||||||
for _, b in ipairs(carry.made) do
|
|
||||||
if test.id_of(b.node) == p.id and gui.is_enabled(b.node, true) then
|
|
||||||
b.can_action = true
|
|
||||||
b.last_pressed_time = p.t
|
|
||||||
b.hover:set_hover(true)
|
|
||||||
found = b
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if found then
|
|
||||||
carry.pressed = { node = found.node, id = p.id, t = p.t }
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Draw and bind. Returns { root, nodes, w, h, button }.
|
|
||||||
function M.new(g, parent, label, x, y, opts)
|
|
||||||
opts = opts or {}
|
|
||||||
local root, nodes, w = M.draw(parent, label, x, y, opts)
|
|
||||||
local b = { root = root, nodes = nodes, w = w, h = gui.get_size(root).y }
|
|
||||||
if not opts.disabled then
|
|
||||||
b.button = M.bind(g, root, nodes, opts)
|
|
||||||
end
|
|
||||||
return b
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Busy (the web's aria-busy + spinner): dimmed, label hidden, a rotating square.
|
|
||||||
function M.set_busy(root)
|
|
||||||
local look = looks[root]
|
|
||||||
local size = gui.get_size(root)
|
|
||||||
if look then
|
|
||||||
look.busy = true
|
|
||||||
for _, p in ipairs(look.parts) do
|
|
||||||
local c = gui.get_color(p[1])
|
|
||||||
gui.set_color(p[1], vmath.vector4(c.x, c.y, c.z, 0.2))
|
|
||||||
end
|
|
||||||
local f = gui.get_color(root)
|
|
||||||
gui.set_color(root, vmath.vector4(f.x, f.y, f.z, f.w * 0.6))
|
|
||||||
local e = gui.get_color(look.edge)
|
|
||||||
gui.set_color(look.edge, vmath.vector4(e.x, e.y, e.z, e.w * 0.6))
|
|
||||||
end
|
|
||||||
local spin = look and look.variant == "primary" and "on_cyan" or "cyan_hi"
|
|
||||||
local sp = ui.outline(root, size.x / 2 - 7, size.y / 2 - 7, 14, 14, spin)
|
|
||||||
gui.set_pivot(sp, gui.PIVOT_CENTER)
|
|
||||||
gui.set_position(sp, vec(size.x / 2, -size.y / 2))
|
|
||||||
gui.animate(sp, "euler.z", -360, gui.EASING_LINEAR, 0.9, 0, nil, gui.PLAYBACK_LOOP_FORWARD)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Whether draw() made this node (a button with looks).
|
|
||||||
function M.is_button(root)
|
|
||||||
return looks[root] ~= nil
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Error feedback on a button (the web's fx-shake: 0.4 s).
|
|
||||||
function M.shake(root)
|
|
||||||
anim.shake(root, 6, 0.4)
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
-- Card (the web's .card / .store-card): an image column on the left and a text column
|
|
||||||
-- that hugs its content, laid out with modules/flex (a column of boxes, measured text: a
|
|
||||||
-- paragraph's height comes from its wrapped lines), with an optional footer block pinned to the
|
|
||||||
-- bottom so buttons line up across a row; a card narrower than card_min_w puts the image
|
|
||||||
-- on top instead (the web's card). card.grid lays cards out in columns (as many as
|
|
||||||
-- card_col_min allows, like the web's grid; centered and capped by the caller), equal
|
|
||||||
-- heights per row, and
|
|
||||||
-- registers them for the panel entrance (g.frames, staggered). Store and
|
|
||||||
-- Advisors use it; the Refinery's recipes too (opts media = 0: no image).
|
|
||||||
--
|
|
||||||
-- y = card.grid(g, parent, x, y, inner, items, function(c, item)
|
|
||||||
-- c:image(item.image)
|
|
||||||
-- c:title(item.name)
|
|
||||||
-- c:para(item.description)
|
|
||||||
-- c:text("+3 per cycle", "small")
|
|
||||||
-- c:footer(52, function(f, w) ... build into f (0..w wide, 52 tall) ... end)
|
|
||||||
-- c.click = fn -- (optional) a click anywhere on the card; c.id its test id
|
|
||||||
-- c.selected = true -- (optional) cyan edge and inner line
|
|
||||||
-- c.edge_color = { "ready", 0.45 } -- (optional) another edge (a hired advisor), and
|
|
||||||
-- c.edge_hover = { "ready", 0.7 } -- its hover
|
|
||||||
-- end)
|
|
||||||
--
|
|
||||||
-- Layout: c:tree(h) is the card as a flex tree; refresh solves it at its content height,
|
|
||||||
-- set_height at the row's. Slots (anim.group) and NW paragraphs are the boxes' nodes;
|
|
||||||
-- what's inside a slot is placed by hand, relative to it.
|
|
||||||
|
|
||||||
local theme = require("modules.theme")
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
local images = require("modules.images")
|
|
||||||
local anim = require("modules.anim")
|
|
||||||
local button = require("components.button")
|
|
||||||
local panel = require("components.panel")
|
|
||||||
local blocks = require("modules.blocks")
|
|
||||||
local flex = require("modules.flex")
|
|
||||||
local flex_gui = require("modules.flex_gui")
|
|
||||||
|
|
||||||
local S = theme.size
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
M.__index = M
|
|
||||||
|
|
||||||
local PAD = 14
|
|
||||||
|
|
||||||
local function vec(x, y)
|
|
||||||
return vmath.vector3(x, y, 0)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Columns for a width, like the web's grid (minmax(250px, 1fr)): count and card width.
|
|
||||||
-- Cards at least card_min_w wide keep the image column on the left; narrower ones put the
|
|
||||||
-- image on top (the web's .card: a 200 px media strip, then the body).
|
|
||||||
function M.columns(inner)
|
|
||||||
local cols = math.max(1, math.floor((inner + S.gap) / (S.card_col_min + S.gap)))
|
|
||||||
return cols, math.floor((inner - (cols - 1) * S.gap) / cols)
|
|
||||||
end
|
|
||||||
|
|
||||||
local STACK_MEDIA = 200 -- the image strip's height on a stacked card (app.css .card-media)
|
|
||||||
|
|
||||||
-- Image column width for a card width.
|
|
||||||
function M.media_size(cw)
|
|
||||||
return math.max(120, math.min(156, math.floor(cw * 0.3)))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A card at (x, y) of parent, w wide. opts: media (image column width, 0 for none: just
|
|
||||||
-- the text column), pad (the text column's padding on every side; default 14 across,
|
|
||||||
-- 12 above, 14 below).
|
|
||||||
function M.new(g, parent, x, y, w, opts)
|
|
||||||
opts = opts or {}
|
|
||||||
local stacked = opts.media == nil and w < S.card_min_w
|
|
||||||
local m = stacked and STACK_MEDIA or (opts.media or M.media_size(w))
|
|
||||||
local c = setmetatable({ g = g, x = x, y = y, w = w, m = m, footer_h = 0, stacked = stacked, pad = opts.pad, items = {} }, M)
|
|
||||||
c.root = ui.rect(parent, x, y, w, 1, "panel")
|
|
||||||
if m > 0 then
|
|
||||||
c.media = ui.rect(c.root, 0, 0, stacked and w or m, stacked and m or 1, "bg")
|
|
||||||
end
|
|
||||||
c.edge = ui.outline(c.root, 0, 0, w, 1, "edge")
|
|
||||||
-- The text column's padding (top, right, bottom, left) and width.
|
|
||||||
if m == 0 then
|
|
||||||
local side = opts.pad or PAD
|
|
||||||
c.padding = { opts.pad or 12, side, opts.pad or 14, side }
|
|
||||||
c.bw = w - side * 2
|
|
||||||
elseif stacked then
|
|
||||||
c.padding = { 14, PAD, 14, PAD }
|
|
||||||
c.bw = w - PAD * 2
|
|
||||||
else
|
|
||||||
c.padding = { 12, PAD, 14, PAD }
|
|
||||||
c.bw = w - m - PAD * 2
|
|
||||||
end
|
|
||||||
c.body = anim.group(c.root, 0, 0, c.bw, 1)
|
|
||||||
return c
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The image (a catalog URL) in the image column, square at the top (stacked: square in
|
|
||||||
-- the middle of the strip).
|
|
||||||
function M:image(url)
|
|
||||||
local node = ui.rect(self.media, self.stacked and math.floor((self.w - self.m) / 2) or 0, 0, self.m, self.m, "none")
|
|
||||||
images.set(node, url)
|
|
||||||
self.image_node = node
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A box in the text column (flex: modules/flex.lua).
|
|
||||||
local function add(self, box, gap)
|
|
||||||
if gap and gap > 0 then
|
|
||||||
self:gap(gap)
|
|
||||||
end
|
|
||||||
self.items[#self.items + 1] = box
|
|
||||||
return box.node
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Vertical space.
|
|
||||||
function M:gap(px)
|
|
||||||
self.items[#self.items + 1] = { style = { height = px } }
|
|
||||||
end
|
|
||||||
|
|
||||||
-- One line of text in a slot h tall (default 18; its middle 8 px down, like the web's
|
|
||||||
-- line boxes), cut with ... to the column. style: theme.text name or table.
|
|
||||||
function M:text(str, style, h)
|
|
||||||
local slot = anim.group(self.body, 0, 0, self.bw, h or 18)
|
|
||||||
local t = ui.label(slot, str, style or "body", 0, 8)
|
|
||||||
if not ui.needs_rich(str) then
|
|
||||||
ui.fit_text(t, self.bw)
|
|
||||||
end
|
|
||||||
add(self, { node = slot, style = { width = self.bw, height = h or 18 } })
|
|
||||||
return t
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The card's title (uppercase, semibold; a 22 px slot).
|
|
||||||
function M:title(str)
|
|
||||||
return self:text(str, { font = "sans_semi", px = 15, color = "text", tracking = 0.1, upper = true }, 22)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A paragraph that wraps at the column width (its height measured).
|
|
||||||
function M:para(str, style, gap)
|
|
||||||
style = type(style) == "string" and theme.text[style] or style or { font = "sans", px = 13, color = "text_2" }
|
|
||||||
local n = ui.text(self.body, str, style, 0, 0, gui.PIVOT_NW)
|
|
||||||
gui.set_line_break(n, true)
|
|
||||||
local s = gui.get_scale(n).x
|
|
||||||
gui.set_size(n, vec(self.bw / s, 10))
|
|
||||||
return add(self, { node = n, size = false, style = { width = self.bw }, measure = flex_gui.text_measure(n) }, gap)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Any node (its size, or a text's metrics, is what counts; pivot NW or W).
|
|
||||||
function M:node(n, gap)
|
|
||||||
gui.set_parent(n, self.body, false)
|
|
||||||
local measure
|
|
||||||
if gui.get_text(n) then
|
|
||||||
measure = flex_gui.text_measure(n)
|
|
||||||
else
|
|
||||||
measure = function()
|
|
||||||
local sz, sc = gui.get_size(n), gui.get_scale(n)
|
|
||||||
return sz.x * sc.x, sz.y * sc.y
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return add(self, { node = n, size = false, style = { align_self = "start" }, measure = measure }, gap)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A block built by fn(parent, w) (stat rows, chips), top-left based: of fixed height h,
|
|
||||||
-- or c:block(fn, gap) where fn returns the height it used.
|
|
||||||
function M:block(h, fn, gap)
|
|
||||||
if type(h) == "function" then
|
|
||||||
h, fn, gap = nil, h, fn
|
|
||||||
end
|
|
||||||
local n = anim.group(self.body, 0, 0, self.bw, h or 1)
|
|
||||||
local used = fn(n, self.bw)
|
|
||||||
return add(self, { node = n, style = { width = self.bw, height = h or used or 0 } }, gap)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Something clickable inside the card's text (a link): fn runs once the card's own click
|
|
||||||
-- is bound, so it gets its clicks first.
|
|
||||||
function M:after_bind(fn)
|
|
||||||
self.late = self.late or {}
|
|
||||||
self.late[#self.late + 1] = fn
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The bottom block (buttons): h tall, built by fn(parent, w) once the row's height is
|
|
||||||
-- known; it sits at the bottom of the card.
|
|
||||||
function M:footer(h, fn)
|
|
||||||
self.footer_h, self.footer_fn = h, fn
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The card as a flex tree (modules/flex): the image column (or strip) and the text
|
|
||||||
-- column, whose items stack from the top and whose footer a growing spacer pushes to the
|
|
||||||
-- bottom; h: the card's height (nil: its content's, at least the image column's).
|
|
||||||
function M:tree(h)
|
|
||||||
local kids = {}
|
|
||||||
for i, b in ipairs(self.items) do
|
|
||||||
kids[i] = b
|
|
||||||
end
|
|
||||||
kids[#kids + 1] = { style = { grow = 1 } }
|
|
||||||
self.footer_box = { style = { width = self.bw, height = self.footer_h } }
|
|
||||||
kids[#kids + 1] = self.footer_box
|
|
||||||
local body = { node = self.body, style = { direction = "column", align = "start", grow = 1, padding = self.padding }, children = kids }
|
|
||||||
local root = { node = self.root, style = { width = self.w, height = h }, children = { body } }
|
|
||||||
if self.stacked then
|
|
||||||
root.style.direction = "column"
|
|
||||||
table.insert(root.children, 1, { node = self.media, style = { height = self.m } })
|
|
||||||
elseif self.m > 0 then
|
|
||||||
root.style.min_height = self.m
|
|
||||||
table.insert(root.children, 1, { node = self.media, style = { width = self.m } })
|
|
||||||
else
|
|
||||||
root.style.direction = "column"
|
|
||||||
end
|
|
||||||
return flex.solve(root, self.x, self.y)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Lay out the text column; returns the card's natural height.
|
|
||||||
function M:refresh()
|
|
||||||
local t = self:tree()
|
|
||||||
flex.apply(t)
|
|
||||||
self.natural = t.h
|
|
||||||
return self.natural
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The card's final height (the tallest in its row): sizes the box and places the footer.
|
|
||||||
function M:set_height(h)
|
|
||||||
self.h = h
|
|
||||||
local t = self:tree(h)
|
|
||||||
flex.apply(t)
|
|
||||||
gui.set_size(self.edge, vec(self.w, h))
|
|
||||||
if self.stacked then
|
|
||||||
ui.rect(self.root, 0, self.m, self.w, 1, "line")
|
|
||||||
elseif self.m > 0 then
|
|
||||||
ui.rect(self.root, self.m, 0, 1, h, "line")
|
|
||||||
end
|
|
||||||
if self.selected then
|
|
||||||
gui.set_color(self.edge, ui.color("cyan"))
|
|
||||||
ui.outline(self.root, 1, 1, self.w - 2, h - 2, "cyan", 0.6)
|
|
||||||
elseif self.edge_color then
|
|
||||||
gui.set_color(self.edge, ui.color_spec(self.edge_color))
|
|
||||||
end
|
|
||||||
-- Hover: the edge brightens (app.css .card:hover, 0.15 s). Bound before the footer's
|
|
||||||
-- buttons: Druid gives the last made input first, so they get their clicks.
|
|
||||||
local edge = self.edge
|
|
||||||
local base = gui.get_color(edge)
|
|
||||||
local b = button.bind(self.g, self.root, nil, { id = self.id, on_click = self.click })
|
|
||||||
b.hover.on_mouse_hover:subscribe(function(_, on)
|
|
||||||
gui.cancel_animations(edge, "color")
|
|
||||||
local lit = self.selected and ui.color("cyan") or (self.edge_color and ui.color_spec(self.edge_hover or self.edge_color)) or ui.color("line_hi")
|
|
||||||
gui.animate(edge, "color", on and lit or base, gui.EASING_OUTQUAD, 0.15)
|
|
||||||
end)
|
|
||||||
for _, fn in ipairs(self.late or {}) do
|
|
||||||
fn()
|
|
||||||
end
|
|
||||||
if self.footer_fn then
|
|
||||||
local fb = self.footer_box
|
|
||||||
local f = anim.group(self.root, fb.x - self.x, fb.y - self.y, fb.w, fb.h)
|
|
||||||
self.footer_fn(f, self.bw)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A grid of cards from y; returns the y under it. make(c, item) fills one card. opts: the
|
|
||||||
-- cards' (M.new); opts.key(item): keep the card (modules/blocks) while opts.sig(item) (what
|
|
||||||
-- make shows besides the item; default the item alone) and its place stay the same.
|
|
||||||
function M.grid(g, parent, x, y, inner, items, make, opts)
|
|
||||||
local cols, cw = M.columns(inner)
|
|
||||||
local keyed = opts and opts.key
|
|
||||||
local i = 1
|
|
||||||
while i <= #items do
|
|
||||||
local row = {}
|
|
||||||
for col = 1, cols do
|
|
||||||
local item = items[i]
|
|
||||||
if not item then
|
|
||||||
break
|
|
||||||
end
|
|
||||||
local r = { item = item, x = x + (col - 1) * (cw + S.gap) }
|
|
||||||
if keyed then
|
|
||||||
r.key = opts.key(item)
|
|
||||||
r.sig = blocks.sig(r.x, y, cw, item, opts.sig and opts.sig(item) or false)
|
|
||||||
r.old = blocks.peek(g, r.key, r.sig) -- (kept, if its row's height stays the same)
|
|
||||||
end
|
|
||||||
if r.old then
|
|
||||||
r.natural = r.old.card.natural
|
|
||||||
else
|
|
||||||
if r.key then
|
|
||||||
r.blk = blocks.open(g, r.key, r.sig, parent)
|
|
||||||
end
|
|
||||||
r.c = M.new(g, r.blk and r.blk.root or parent, r.x, y, cw, opts)
|
|
||||||
make(r.c, item)
|
|
||||||
r.natural = r.c:refresh()
|
|
||||||
if r.blk then
|
|
||||||
r.blk.card = r.c
|
|
||||||
blocks.close(g, r.blk)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
row[#row + 1] = r
|
|
||||||
i = i + 1
|
|
||||||
end
|
|
||||||
local rh = 0
|
|
||||||
for _, r in ipairs(row) do
|
|
||||||
rh = math.max(rh, r.natural)
|
|
||||||
end
|
|
||||||
for _, r in ipairs(row) do
|
|
||||||
if r.old and r.old.card.h == rh then
|
|
||||||
r.blk = blocks.open(g, r.key, r.sig, parent) -- (kept)
|
|
||||||
r.c = r.blk.card
|
|
||||||
else
|
|
||||||
if r.old then
|
|
||||||
-- The row's height changed: made again at it.
|
|
||||||
blocks.drop(g, r.old)
|
|
||||||
r.blk = blocks.open(g, r.key, r.sig, parent)
|
|
||||||
r.c = M.new(g, r.blk.root, r.x, y, cw, opts)
|
|
||||||
make(r.c, r.item)
|
|
||||||
r.c:refresh()
|
|
||||||
r.blk.card = r.c
|
|
||||||
elseif r.blk then
|
|
||||||
blocks.reopen(g, r.blk) -- (its hover and footer are the card's)
|
|
||||||
end
|
|
||||||
r.c:set_height(rh)
|
|
||||||
if r.blk then
|
|
||||||
blocks.close(g, r.blk)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local c = r.c
|
|
||||||
if g.frames then
|
|
||||||
g.frames[#g.frames + 1] = panel.frame(c.root, c.edge, cw, rh) -- staggered entrance
|
|
||||||
end
|
|
||||||
if g.fx then
|
|
||||||
g.fx:box(c.root) -- (an action's sweep covers the card)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
y = y + rh + S.gap
|
|
||||||
end
|
|
||||||
return y
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
-- Chip / badge (the web's .badge, level chips, advisor tags): a 1 px outlined box with
|
|
||||||
-- a short label, optionally filled. Returns the box node and its width.
|
|
||||||
--
|
|
||||||
-- chip.new(parent, "Y2 C1", x, y, { color = "line_hi", text = "text_2", font = "mono" })
|
|
||||||
-- chip.new(parent, 3, x, y, { color = "ready", font = "mono", px = 12, min_w = 22 })
|
|
||||||
-- chip.new(parent, "Turbo core", x, y, { color = "violet", text = "violet_hi", upper = true })
|
|
||||||
--
|
|
||||||
-- y is the chip's middle; opts: color (edge; the text too unless opts.text), alpha (edge
|
|
||||||
-- alpha), text, fill (fill alpha, in the edge color), font, px, tracking, upper, h (18),
|
|
||||||
-- pad (6 each side), min_w, right (x is the right edge).
|
|
||||||
--
|
|
||||||
-- chip.grade(parent, "B", x, y, { h = 16 }) -- a moon grade badge (D..S), GRADE_W wide
|
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
function M.new(parent, label, x, y, opts)
|
|
||||||
opts = opts or {}
|
|
||||||
local color = opts.color or "line_hi"
|
|
||||||
local style = { font = opts.font or "sans_semi", px = opts.px or 11, color = opts.text or color,
|
|
||||||
tracking = opts.tracking or (opts.font == "mono" and 0 or 0.12), upper = opts.upper }
|
|
||||||
local h = opts.h or 18
|
|
||||||
local pad = opts.pad or 6
|
|
||||||
local probe = ui.text(parent, label, style, 0, 0)
|
|
||||||
local w = math.max(opts.min_w or 0, math.floor(ui.text_width(probe) + pad * 2))
|
|
||||||
gui.delete_node(probe)
|
|
||||||
local left = opts.right and (x - w) or x
|
|
||||||
local box = ui.outline(parent, left, y - h / 2, w, h, color, opts.alpha)
|
|
||||||
if opts.fill then
|
|
||||||
-- A plain box holds the fill and the edge above it (gui.move_below on a child would
|
|
||||||
-- move the fill out to box's parent, at its 0, 0).
|
|
||||||
local edge = box
|
|
||||||
box = ui.rect(parent, left, y - h / 2, w, h, color, opts.fill)
|
|
||||||
gui.set_parent(edge, box, false)
|
|
||||||
gui.set_position(edge, vmath.vector3(0, 0, 0))
|
|
||||||
end
|
|
||||||
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
|
|
||||||
return box, w
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Moon grade badges: edge/text color per grade, cheapest to best.
|
|
||||||
M.GRADE = { D = "line_hi", C = "text_2", B = "cyan", A = "violet_hi", S = "ready" }
|
|
||||||
M.GRADE_W = 18
|
|
||||||
|
|
||||||
function M.grade(parent, grade, x, y, opts)
|
|
||||||
opts = opts or {}
|
|
||||||
local color = M.GRADE[grade] or "line_hi"
|
|
||||||
return M.new(parent, grade, x, y, { color = color, text = grade == "D" and "muted" or color,
|
|
||||||
font = "mono_semi", px = opts.px or 11, h = opts.h or 16, pad = 3, min_w = opts.w or M.GRADE_W,
|
|
||||||
right = opts.right })
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
-- Dialog (the web's .dialog): a modal panel over the game screen, in the same gui (on its
|
|
||||||
-- own layer, with its own Druid instance), so a confirmed action runs in the game
|
|
||||||
-- script and a page rebuild doesn't touch it. One at a time: g.dialog (core/main.lua).
|
|
||||||
--
|
|
||||||
-- g.dialog:open({ title, message, w, h,
|
|
||||||
-- ok = { label, price, danger, act = "<action>", params = {...} } or { label, on_ok = fn },
|
|
||||||
-- cancel = "Cancel" (the other button; "Close" without ok),
|
|
||||||
-- build = function(d, box, w, h) ... end, -- custom content instead of the message
|
|
||||||
-- title_x = 24 }) -- the title's x (room for an image left of it)
|
|
||||||
-- menu = { right, top }: a dropdown instead (the header's settings menu): no dimmed
|
|
||||||
-- backdrop (a click outside still closes it), no title or buttons, its top-right
|
|
||||||
-- corner `right` from the page's right edge and `top` down; build makes the content.
|
|
||||||
-- g.dialog:close("ok" | "cancel")
|
|
||||||
-- g.dialog:is_open()
|
|
||||||
--
|
|
||||||
-- In build, d.reply(action, params) closes and runs an action (effects start from the
|
|
||||||
-- clicked button); d:button(parent, label, x, y, opts) makes a button (components/button
|
|
||||||
-- opts plus on_click / id). The message wraps (ui.wrap, may hold ₥); the panel grows to
|
|
||||||
-- fit it. Test ids: "dialog:ok", "dialog:cancel".
|
|
||||||
--
|
|
||||||
-- Motion (app.css dialog-in/-out, ui.js): the backdrop fades in (0.28 s); the panel
|
|
||||||
-- scales up from .94, rises 8 px and unclips from its middle (0.28 s overshoot); a cyan
|
|
||||||
-- scan line sweeps down it; title, message and buttons rise in, 60 ms apart. Closing:
|
|
||||||
-- fade, scale .97, clip to the middle (0.18 s). Sounds: open (a confirm) or modal (custom
|
|
||||||
-- content, after the action's own), confirm on OK, close otherwise. While open the page
|
|
||||||
-- gets no input and its hovers are dropped (druid:reset_hovers); they come back on close.
|
|
||||||
|
|
||||||
local druid = require("druid.druid")
|
|
||||||
local theme = require("modules.theme")
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
local anim = require("modules.anim")
|
|
||||||
local sfx = require("modules.sfx")
|
|
||||||
local button = require("components.button")
|
|
||||||
local test = require("modules.test")
|
|
||||||
local flex = require("modules.flex")
|
|
||||||
local fg = require("modules.flex_gui")
|
|
||||||
|
|
||||||
local S = theme.size
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
M.__index = M
|
|
||||||
|
|
||||||
local W0, H0 = 460, 230
|
|
||||||
local SCRIM = theme.color.scrim
|
|
||||||
|
|
||||||
local function vec(x, y)
|
|
||||||
return vmath.vector3(x, y, 0)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- g: the game screen; layer: a node over the page (top-left = page top-left).
|
|
||||||
function M.new(g, layer)
|
|
||||||
return setmetatable({ g = g, layer = layer }, M)
|
|
||||||
end
|
|
||||||
|
|
||||||
function M:is_open()
|
|
||||||
return self.root ~= nil and not self.closing
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A button inside the dialog (its own Druid instance). opts: components/button draw
|
|
||||||
-- options plus on_click, id.
|
|
||||||
function M:button(parent, label, x, y, opts)
|
|
||||||
opts = opts or {}
|
|
||||||
local root, nodes, w = ui.button(parent, label, x, y, opts)
|
|
||||||
local d = self
|
|
||||||
if not opts.disabled then
|
|
||||||
button.bind(self.g, root, nodes, { id = opts.id, druid = self.druid, on_click = function()
|
|
||||||
d.last_button = root
|
|
||||||
if opts.on_click then
|
|
||||||
opts.on_click()
|
|
||||||
end
|
|
||||||
end })
|
|
||||||
end
|
|
||||||
return root, nodes, w
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Close and run an action on the game screen (its effects start at `from`, a node, or
|
|
||||||
-- at the page button that opened the dialog: ok.from, a rect from fx:rect_of with its
|
|
||||||
-- test id). quiet: close without the confirm sound (the buy modal's buttons, main.js).
|
|
||||||
function M:act(name, params, from, quiet)
|
|
||||||
local g = self.g
|
|
||||||
local opener = self.opts and self.opts.ok and self.opts.ok.from
|
|
||||||
if opener then
|
|
||||||
g.fx.last_origin = { x = opener.x, y = opener.y, w = opener.w, h = opener.h, id = opener.id }
|
|
||||||
else
|
|
||||||
g.fx:origin(from)
|
|
||||||
end
|
|
||||||
self:close(quiet and "quiet" or "ok")
|
|
||||||
require("screens.game.core.session").act(g, name, params or {})
|
|
||||||
end
|
|
||||||
|
|
||||||
function M:open(opts)
|
|
||||||
if self.root then
|
|
||||||
self:remove()
|
|
||||||
end
|
|
||||||
local g = self.g
|
|
||||||
opts = opts or {}
|
|
||||||
self.opts = opts
|
|
||||||
self.closing = false
|
|
||||||
self.druid = druid.new(g)
|
|
||||||
test.begin_build("dialog")
|
|
||||||
test.set_scope("dialog")
|
|
||||||
g.druid:reset_hovers()
|
|
||||||
local W, H = g.size[1], g.size[2]
|
|
||||||
local w = opts.w or W0
|
|
||||||
local d = self
|
|
||||||
self.reply = function(name, params)
|
|
||||||
d:act(name, params, d.last_button, true)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Backdrop (a click on it cancels), then the panel in a clip that unclips from its middle.
|
|
||||||
local root = ui.rect(self.layer, 0, 0, W, H, "none")
|
|
||||||
self.root = root
|
|
||||||
local menu = opts.menu
|
|
||||||
local scrim = ui.rect(root, 0, 0, W, H, menu and "none" or SCRIM)
|
|
||||||
self.scrim = scrim
|
|
||||||
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48)
|
|
||||||
gui.delete_node(probe)
|
|
||||||
local h = menu and opts.h or math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
|
|
||||||
local holder = anim.group(root, menu and W - menu.right - w / 2 or W / 2, menu and menu.top + h / 2 or H / 2, 1, 1)
|
|
||||||
gui.set_pivot(holder, gui.PIVOT_CENTER)
|
|
||||||
self.holder = holder
|
|
||||||
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
|
|
||||||
gui.set_position(clip, vec(0, 0))
|
|
||||||
gui.set_pivot(clip, gui.PIVOT_CENTER)
|
|
||||||
gui.set_clipping_mode(clip, gui.CLIPPING_MODE_STENCIL)
|
|
||||||
gui.set_clipping_visible(clip, false)
|
|
||||||
self.clip, self.w, self.h = clip, w, h
|
|
||||||
local box = ui.panel(clip, -w / 2, -h / 2, w, h)
|
|
||||||
gui.set_color(box, ui.color("field", 0.96))
|
|
||||||
self.box = box
|
|
||||||
|
|
||||||
-- Input: Druid gives the last created component input first, so: the backdrop (a
|
|
||||||
-- click on it cancels), then a blocker over the panel, then the panel's buttons.
|
|
||||||
self.druid:new_button(scrim, function()
|
|
||||||
d:close("cancel")
|
|
||||||
end):set_animations_disabled()
|
|
||||||
self.druid:new_blocker(clip)
|
|
||||||
-- (Druid's back handler takes key_back only: Esc is a hotkey.)
|
|
||||||
self.druid:new_back_handler(function()
|
|
||||||
d:close("cancel")
|
|
||||||
end)
|
|
||||||
self.druid:new_hotkey("key_esc", function()
|
|
||||||
d:close("cancel")
|
|
||||||
end)
|
|
||||||
|
|
||||||
if menu then
|
|
||||||
opts.build(self, box, w, h)
|
|
||||||
test.set_scope("page")
|
|
||||||
sfx.play("toggle")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Title, message (or custom content), buttons: three groups that rise in turn.
|
|
||||||
local g_title = anim.group(box, 0, 0)
|
|
||||||
local g_body = anim.group(box, 0, 0)
|
|
||||||
local g_actions = anim.group(box, 0, 0)
|
|
||||||
-- The panel's layout (geometry only: each group builds into its own part): the title
|
|
||||||
-- (its middle 34 down), the message from 58, the buttons at the bottom right, OK
|
|
||||||
-- left of Cancel (Cancel is made first).
|
|
||||||
local cancel_label = opts.cancel or (opts.ok and "Cancel" or "Close")
|
|
||||||
local ok = opts.ok
|
|
||||||
local danger = ok and ok.danger
|
|
||||||
local ok_opts = ok and { price = ok.price, variant = danger and "danger" or "primary" }
|
|
||||||
local cancel_box = { style = { width = fg.button_w(root, cancel_label, {}), height = S.button_h, shrink = 0 } }
|
|
||||||
local ok_box = ok and { style = { width = fg.button_w(root, ok.label or "OK", ok_opts), height = S.button_h, shrink = 0 } }
|
|
||||||
local tree = flex.box({ direction = "column", width = w, height = h, padding = { 24, 24, 24, opts.title_x or 24 } }, {
|
|
||||||
{ style = { height = 20 }, id = "title" },
|
|
||||||
flex.box({ grow = 1 }),
|
|
||||||
flex.box({ justify = "end", gap = 10 }, ok_box and { ok_box, cancel_box } or { cancel_box }),
|
|
||||||
})
|
|
||||||
flex.solve(tree, 0, 0)
|
|
||||||
local title = tree.children[1]
|
|
||||||
ui.label(g_title, opts.title or "Notice", "h2", title.x, title.y + title.h / 2)
|
|
||||||
if opts.build then
|
|
||||||
opts.build(self, g_body, w, h)
|
|
||||||
else
|
|
||||||
ui.wrap(g_body, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 24, title.y + title.h + 14, w - 48)
|
|
||||||
end
|
|
||||||
local by = cancel_box.y
|
|
||||||
self:button(g_actions, cancel_label, cancel_box.x, cancel_box.y, {
|
|
||||||
id = "dialog:cancel", on_click = function() d:close("cancel") end,
|
|
||||||
})
|
|
||||||
if ok then
|
|
||||||
local ob
|
|
||||||
ob = self:button(g_actions, ok.label or "OK", ok_box.x, ok_box.y, {
|
|
||||||
id = "dialog:ok", price = ok.price,
|
|
||||||
variant = danger and "danger" or "primary",
|
|
||||||
fill = danger and { "danger", 0.16 } or nil, edge = danger and { "danger", 1 } or nil,
|
|
||||||
accent = danger and "danger" or nil,
|
|
||||||
on_click = function()
|
|
||||||
if ok.on_ok then
|
|
||||||
d:close("ok")
|
|
||||||
ok.on_ok()
|
|
||||||
elseif ok.act then
|
|
||||||
d:act(ok.act, ok.params, ob)
|
|
||||||
else
|
|
||||||
d:close("ok")
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
self.by = by
|
|
||||||
test.set_scope("page")
|
|
||||||
self:animate_in(g_title, g_body, g_actions)
|
|
||||||
if opts.build then
|
|
||||||
sfx.play("modal", { delay = 150 })
|
|
||||||
else
|
|
||||||
sfx.play("open")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function M:animate_in(...)
|
|
||||||
local groups = { ... }
|
|
||||||
local holder, clip, scrim = self.holder, self.clip, self.scrim
|
|
||||||
gui.set_color(scrim, vmath.vector4(SCRIM.x, SCRIM.y, SCRIM.z, 0))
|
|
||||||
gui.animate(scrim, "color.w", SCRIM.w, anim.ease.ease_out, 0.28)
|
|
||||||
-- Content fades in from under veils in the panel's color (anim.veil): title, message,
|
|
||||||
-- buttons in turn.
|
|
||||||
local field = ui.color("field")
|
|
||||||
local w, h, by = self.w, self.h, self.by
|
|
||||||
local regions = { { 2, 48 }, { 50, by - 60 }, { by - 10, h - by + 8 } } -- (the edge stays)
|
|
||||||
for i, r in ipairs(regions) do
|
|
||||||
anim.veil(self.box, 2, r[1], w - 4, r[2], field, (i - 1) * 0.06, anim.reduced and 0.12 or 0.35)
|
|
||||||
end
|
|
||||||
if anim.reduced then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local p = gui.get_position(holder)
|
|
||||||
gui.set_scale(holder, vmath.vector3(0.94, 0.94, 1))
|
|
||||||
gui.animate(holder, "scale", vmath.vector3(1, 1, 1), anim.ease.overshoot, 0.28)
|
|
||||||
gui.set_position(holder, vec(p.x, p.y - 8))
|
|
||||||
gui.animate(holder, "position.y", p.y, anim.ease.overshoot, 0.28)
|
|
||||||
local size = gui.get_size(clip)
|
|
||||||
gui.set_size(clip, vec(size.x, size.y * 0.04))
|
|
||||||
gui.animate(clip, "size", size, anim.ease.overshoot, 0.28)
|
|
||||||
anim.sweep(self.box, self.w, self.h, ui.color("cyan"), 0.7, 0.05)
|
|
||||||
for i, grp in ipairs(groups) do
|
|
||||||
anim.rise(grp, (i - 1) * 0.06, 6, 0.35) -- rise-in: 0.35 s, 60 ms apart
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- value: "ok" (the confirm sound), "quiet" (none: the action makes its own) or "cancel".
|
|
||||||
function M:close(value)
|
|
||||||
if not self.root or self.closing then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
self.closing = true
|
|
||||||
self.closing_at = socket.gettime()
|
|
||||||
if value ~= "quiet" then
|
|
||||||
sfx.play(value == "ok" and "confirm" or "close")
|
|
||||||
end
|
|
||||||
local d = self
|
|
||||||
local function done()
|
|
||||||
d:remove()
|
|
||||||
end
|
|
||||||
local dur = anim.reduced and 0.12 or 0.18
|
|
||||||
gui.animate(self.scrim, "color.w", 0, anim.ease.ease_in, dur, 0, done)
|
|
||||||
anim.veil(self.box, 2, 2, self.w - 4, self.h - 4, ui.color("field"), 0, dur, true)
|
|
||||||
if not anim.reduced then
|
|
||||||
gui.animate(self.holder, "scale", vmath.vector3(0.97, 0.97, 1), anim.ease.ease_in, 0.18)
|
|
||||||
local size = gui.get_size(self.clip)
|
|
||||||
gui.animate(self.clip, "size", vec(size.x, size.y * 0.04), anim.ease.ease_in, 0.18)
|
|
||||||
end
|
|
||||||
self.g.rehover = true -- the page's hover comes back under the pointer
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Gone at once (a new dialog, the screen going away).
|
|
||||||
function M:remove()
|
|
||||||
if self.druid then
|
|
||||||
-- A dialog gone in the frame it opened (another one replacing it): Druid's late
|
|
||||||
-- init (a timer.delay(0)) would still run on its deleted nodes.
|
|
||||||
if self.druid._late_init_timer_id then
|
|
||||||
timer.cancel(self.druid._late_init_timer_id)
|
|
||||||
self.druid._late_init_timer_id = nil
|
|
||||||
end
|
|
||||||
self.druid:final()
|
|
||||||
self.druid = nil
|
|
||||||
end
|
|
||||||
if self.root then
|
|
||||||
gui.delete_node(self.root)
|
|
||||||
end
|
|
||||||
test.begin_build("dialog")
|
|
||||||
self.root, self.closing, self.opts, self.closing_at = nil, false, nil, nil
|
|
||||||
end
|
|
||||||
|
|
||||||
-- How long a close may take before its end is forced (the close animation is 0.18 s; its
|
|
||||||
-- callback is what removes the backdrop, and while the backdrop exists the page gets no
|
|
||||||
-- input).
|
|
||||||
local CLOSE_LIMIT = 1
|
|
||||||
|
|
||||||
-- The input invariants (checked every frame by core/main.lua): what blocks the page's
|
|
||||||
-- input exists only while a dialog is open or closing, and a close finishes. Returns a
|
|
||||||
-- description of what was wrong (and is now repaired), or nil.
|
|
||||||
function M:check()
|
|
||||||
if not self.root then
|
|
||||||
if self.druid then
|
|
||||||
self.druid:final()
|
|
||||||
self.druid = nil
|
|
||||||
return "a dialog's Druid instance outlived it"
|
|
||||||
end
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if not self.druid then
|
|
||||||
self:remove()
|
|
||||||
return "a dialog backdrop without its input"
|
|
||||||
end
|
|
||||||
if self.closing and socket.gettime() - (self.closing_at or 0) > CLOSE_LIMIT then
|
|
||||||
self:remove()
|
|
||||||
return "a dialog close never finished"
|
|
||||||
end
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The page was resized: keep the backdrop full and the panel centered.
|
|
||||||
function M:layout(W, H)
|
|
||||||
if not self.root then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
gui.set_size(self.root, vec(W, H))
|
|
||||||
gui.set_size(self.scrim, vec(W, H))
|
|
||||||
gui.cancel_animations(self.holder, "position.y")
|
|
||||||
local menu = self.opts and self.opts.menu
|
|
||||||
if menu then
|
|
||||||
gui.set_position(self.holder, vec(W - menu.right - self.w / 2, -(menu.top + self.h / 2)))
|
|
||||||
else
|
|
||||||
gui.set_position(self.holder, vec(W / 2, -H / 2))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function M:update(dt)
|
|
||||||
if self.druid then
|
|
||||||
self.druid:update(dt)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Returns true while open (the page gets no input then).
|
|
||||||
function M:on_input(action_id, action)
|
|
||||||
if not self.root then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
if self.druid and not self.closing then
|
|
||||||
self.druid:on_input(action_id, action)
|
|
||||||
end
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
function M:on_message(message_id, message, sender)
|
|
||||||
if self.druid then
|
|
||||||
self.druid:on_message(message_id, message, sender)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
-- A long list (Druid Data List over Scroll + Static Grid, druid-notes.md 2.3): only the
|
|
||||||
-- rows in view exist, made by create() as they scroll in and deleted as they leave, so
|
|
||||||
-- hundreds of moons cost a screenful of nodes. Fixed row height; the scroll offset is kept
|
|
||||||
-- per key on g (g.scroll_pos) so it survives rebuilds; wheel and drag scroll.
|
|
||||||
--
|
|
||||||
-- local l = list.new(g, parent, x, y, w, h, {
|
|
||||||
-- key = "moons", row_h = 64, -- the row's slot (gap included)
|
|
||||||
-- create = function(item, index) ... return root[, druid component] end,
|
|
||||||
-- focus_id = function(item) return "moon:" .. item.id end, -- (optional) the test
|
|
||||||
-- -- id its row registers: Tab walks
|
|
||||||
-- -- every row, scrolling (modules/focus)
|
|
||||||
-- on_remove = function(node, item) ... end }) -- (optional) a row scrolled out, before
|
|
||||||
-- -- its node is deleted (not when the
|
|
||||||
-- -- list itself goes in a rebuild)
|
|
||||||
-- l:set_data(items) -- (again with a filtered list: the rows are made again)
|
|
||||||
--
|
|
||||||
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
|
|
||||||
-- places it. A Druid component returned with it (a widget) is removed with the row.
|
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
local blocks = require("modules.blocks")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
M.__index = M
|
|
||||||
|
|
||||||
local function vec(x, y)
|
|
||||||
return vmath.vector3(x, y, 0)
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.new(g, parent, x, y, w, h, opts)
|
|
||||||
local l = setmetatable({ g = g, parent = parent, x = x, y = y, w = w, h = h, key = opts.key, create = blocks.bound(opts.create), -- (rows made as it scrolls are its block's)
|
|
||||||
focus_id = opts.focus_id, row_h = opts.row_h, items = {} }, M)
|
|
||||||
g.scroll_pos = g.scroll_pos or {}
|
|
||||||
l.view = ui.rect(parent, x, y, w, h, "text")
|
|
||||||
gui.set_clipping_mode(l.view, gui.CLIPPING_MODE_STENCIL)
|
|
||||||
gui.set_clipping_visible(l.view, false)
|
|
||||||
l.content = ui.rect(l.view, 0, 0, w, h, "none")
|
|
||||||
-- The grid's prefab: a node of the row's slot size (never shown).
|
|
||||||
l.prefab = ui.rect(l.view, 0, 0, w, opts.row_h, "none")
|
|
||||||
gui.set_enabled(l.prefab, false)
|
|
||||||
l.scroll = g.druid:new_scroll(l.view, l.content)
|
|
||||||
l.scroll:set_horizontal_scroll(false)
|
|
||||||
l.scroll:set_extra_stretch_size(0)
|
|
||||||
l.scroll.style.WHEEL_SCROLL_SPEED = 48
|
|
||||||
l.grid = g.druid:new_grid(l.content, l.prefab, 1)
|
|
||||||
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
|
|
||||||
return l.create(item, index)
|
|
||||||
end)
|
|
||||||
if opts.on_remove then
|
|
||||||
l.data.on_element_remove:subscribe(function(_, _, node, _, item)
|
|
||||||
if not g.tearing_down then
|
|
||||||
opts.on_remove(node, item)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
l.scroll.on_scroll:subscribe(function(_, pos)
|
|
||||||
if g.tearing_down then
|
|
||||||
return -- (the old list emptying itself during a rebuild: keep the offset)
|
|
||||||
end
|
|
||||||
g.scroll_pos[l.key] = pos.y
|
|
||||||
l:place_thumb(pos.y)
|
|
||||||
end)
|
|
||||||
blocks.push(g, "scroll_areas", { view = l.view, content = l.content, key = l.key, max = 0, h = h, list = l, scroll = l.scroll })
|
|
||||||
return l
|
|
||||||
end
|
|
||||||
|
|
||||||
function M:set_data(items)
|
|
||||||
local saved = self.g.scroll_pos[self.key] or 0
|
|
||||||
self.items = items
|
|
||||||
-- At the saved offset first (Druid clamps it to the rows), so the data list makes the
|
|
||||||
-- rows there, not the top ones only to drop them again.
|
|
||||||
local s = self.scroll
|
|
||||||
s:set_size(self.grid:get_size_for(#items))
|
|
||||||
s.target_position.y = saved
|
|
||||||
s:_set_scroll_position(s.position.x, saved)
|
|
||||||
s.target_position.y = s.position.y
|
|
||||||
self.data:set_data(items)
|
|
||||||
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
|
|
||||||
for _, a in ipairs(self.g.scroll_areas) do
|
|
||||||
if a.list == self then
|
|
||||||
a.max = max
|
|
||||||
end
|
|
||||||
end
|
|
||||||
-- A thin bar at the right edge (like components/scroll) when it overflows.
|
|
||||||
if self.track then
|
|
||||||
gui.delete_node(self.track)
|
|
||||||
gui.delete_node(self.thumb)
|
|
||||||
self.track, self.thumb = nil, nil
|
|
||||||
end
|
|
||||||
self.max = max
|
|
||||||
if max > 0 then
|
|
||||||
local track_h = self.h - 8
|
|
||||||
self.thumb_h = math.max(28, math.floor(track_h * self.h / (self.h + max)))
|
|
||||||
self.track = ui.rect(self.parent, self.x + self.w + 4, self.y + 4, 1, track_h, "line")
|
|
||||||
self.thumb = ui.rect(self.parent, self.x + self.w + 3, self.y + 4, 3, self.thumb_h, "cyan", 0.55)
|
|
||||||
end
|
|
||||||
self:place_thumb(self.scroll.position.y)
|
|
||||||
end
|
|
||||||
|
|
||||||
function M:place_thumb(pos_y)
|
|
||||||
if self.thumb then
|
|
||||||
local f = math.max(0, math.min(1, pos_y / self.max))
|
|
||||||
gui.set_position(self.thumb, vec(self.x + self.w + 3, -(self.y + 4 + (self.h - 8 - self.thumb_h) * f)))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
-- Panel (the web's .frame): a translucent box with a thin edge and cyan corner brackets
|
|
||||||
-- (gui/templates/panel.gui), and a body group the content goes in. enter() plays the
|
|
||||||
-- web's entrance (enter.js): the brackets spread from the middle, the content fades in
|
|
||||||
-- from under a veil and rises 6 px; frames nested in it (cards) follow, staggered.
|
|
||||||
--
|
|
||||||
-- local p = panel.new(parent, x, y, w, h) -- p.root (the fill), p.frame, p.body
|
|
||||||
-- ... build into p.body (top-left based, like any parent) ...
|
|
||||||
-- p:enter({ delay = 0, nested = { frames } }) -- once it's built (core/main.lua calls
|
|
||||||
-- -- it when the view changes)
|
|
||||||
-- panel.frame(node, edge, w, h) -- a nested frame (a card) for `nested`
|
|
||||||
|
|
||||||
local theme = require("modules.theme")
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
local anim = require("modules.anim")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
M.__index = M
|
|
||||||
|
|
||||||
function M.new(parent, x, y, w, h)
|
|
||||||
local root, nodes = ui.panel(parent, x, y, w, h)
|
|
||||||
local p = setmetatable({ root = root, frame = ui.part(nodes, "panel", "frame"), w = w, h = h }, M)
|
|
||||||
p.body = anim.group(root, 0, 0, w, h)
|
|
||||||
return p
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A nested frame: a card (fill node holding its content, pivot NW) and its edge (an
|
|
||||||
-- outline over it). For enter's `nested` list.
|
|
||||||
function M.frame(node, edge, w, h)
|
|
||||||
return { node = node, edge = edge, w = w, h = h }
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The entrance. opts: delay, clip (false: the box doesn't unclip; the aside, which holds
|
|
||||||
-- the 3D viewport), nested (frames inside, staggered after this one).
|
|
||||||
function M:enter(opts)
|
|
||||||
opts = opts or {}
|
|
||||||
local delay = opts.delay or 0
|
|
||||||
local veil = theme.color.veil
|
|
||||||
anim.enter_frame({ frame = self.frame, body = self.body, veil = { parent = self.root, color = veil },
|
|
||||||
x = 1, y = 1, w = self.w - 2, h = self.h - 2 }, { delay = delay, clip = false })
|
|
||||||
local frames = {}
|
|
||||||
for _, f in ipairs(opts.nested or {}) do
|
|
||||||
frames[#frames + 1] = { frame = f.edge, veil = { parent = f.node, color = veil }, x = 0, y = 0, w = f.w, h = f.h }
|
|
||||||
end
|
|
||||||
anim.enter(frames, { nested = true, delay = delay })
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
-- List row (wells table rows, the moon list's rows): a full-width row that lights up
|
|
||||||
-- under the mouse (cyan wash, eased), shows its selection (wash and a 2 px cyan bar on
|
|
||||||
-- the left) and runs on_click. Buttons on the row made after it take their clicks first.
|
|
||||||
--
|
|
||||||
-- row.bind(g, node, w, h, { selected = true, on_click = fn, id = "well:<id>", label })
|
|
||||||
--
|
|
||||||
-- A new row's entrance (app.css fx-new: a moon just bought, a drill just deployed): it
|
|
||||||
-- slides in from 28 px right and fades in over a cyan wash (.35, .2 at 40%, then the
|
|
||||||
-- row's own fill), with a 3 px cyan bar on its left until halfway (0.9 s). Rows are
|
|
||||||
-- rebuilt with every state, so the entrance is kept by id and time on g and picks up
|
|
||||||
-- where it was on the rebuilt row.
|
|
||||||
--
|
|
||||||
-- local new = row.fresh(g, "moons", scope, ids) -- ids not there last build (same scope)
|
|
||||||
-- row.mark(g, id) -- start an entrance for id now
|
|
||||||
-- row.enter(g, id, node, w, h, { base = fill, slide = node, fade = fn, veil = color })
|
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
local anim = require("modules.anim")
|
|
||||||
local button = require("components.button")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
M.HOVER, M.SELECTED = 0.05, 0.08
|
|
||||||
|
|
||||||
function M.bind(g, node, w, h, opts)
|
|
||||||
opts = opts or {}
|
|
||||||
local base = ui.color("cyan", opts.selected and M.SELECTED or 0)
|
|
||||||
gui.set_color(node, base)
|
|
||||||
if g.fx then
|
|
||||||
g.fx:box(node) -- (an action's sweep covers the row its button is on)
|
|
||||||
end
|
|
||||||
if opts.selected then
|
|
||||||
ui.rect(node, 0, 0, 2, h - 1, "cyan")
|
|
||||||
end
|
|
||||||
local b = button.bind(g, node, nil, { id = opts.id, label = opts.label, on_click = opts.on_click })
|
|
||||||
b.hover.on_mouse_hover:subscribe(function(_, on)
|
|
||||||
gui.cancel_animations(node, "color")
|
|
||||||
local c = on and ui.color("cyan", opts.selected and M.SELECTED or M.HOVER) or base
|
|
||||||
if button.instant then
|
|
||||||
gui.set_color(node, c)
|
|
||||||
else
|
|
||||||
gui.animate(node, "color", c, gui.EASING_OUTQUAD, 0.12)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
return b
|
|
||||||
end
|
|
||||||
|
|
||||||
---------------------------------------------------------------- new rows
|
|
||||||
|
|
||||||
M.ENTER = 0.9
|
|
||||||
|
|
||||||
local function clock()
|
|
||||||
return socket.gettime()
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Which of ids weren't there the last time fresh() ran for key (as a set), when scope
|
|
||||||
-- (e.g. the moon shown) is the same; nothing the first time or after scope changes.
|
|
||||||
function M.fresh(g, key, scope, ids)
|
|
||||||
g.row_known = g.row_known or {}
|
|
||||||
local was = g.row_known[key]
|
|
||||||
local now_set = {}
|
|
||||||
for _, id in ipairs(ids) do
|
|
||||||
now_set[id] = true
|
|
||||||
end
|
|
||||||
g.row_known[key] = { scope = scope, ids = now_set }
|
|
||||||
local new = {}
|
|
||||||
if was and was.scope == scope then
|
|
||||||
for _, id in ipairs(ids) do
|
|
||||||
if not was.ids[id] then
|
|
||||||
new[id] = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return new, was and was.scope == scope and was.ids or nil
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Start an entrance for id (at = when; default now).
|
|
||||||
function M.mark(g, id, at)
|
|
||||||
g.row_new = g.row_new or {}
|
|
||||||
g.row_new[id] = at or clock()
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Play (or pick up) id's entrance on node (a row, w x h, pivot NW). opts: base (the
|
|
||||||
-- row's resting fill color, vector4; default transparent), slide (the node that slides;
|
|
||||||
-- default node), fade (fade(e): shows the content at e, 0..1, e.g. through the row's own veil), veil (without
|
|
||||||
-- fade: the background color a veil over the row fades from; default the panel's), bar
|
|
||||||
-- (false: no left bar).
|
|
||||||
function M.enter(g, id, node, w, h, opts)
|
|
||||||
local t0 = g.row_new and g.row_new[id]
|
|
||||||
if not t0 then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local age = clock() - t0
|
|
||||||
if age >= M.ENTER then
|
|
||||||
g.row_new[id] = nil
|
|
||||||
return
|
|
||||||
end
|
|
||||||
opts = opts or {}
|
|
||||||
local base = opts.base or ui.color("cyan", 0)
|
|
||||||
local cyan = ui.color("cyan")
|
|
||||||
local slide = opts.slide or node
|
|
||||||
local p0 = gui.get_position(slide)
|
|
||||||
local veil
|
|
||||||
if not opts.fade then
|
|
||||||
veil = ui.rect(node, 0, 0, w, h, opts.veil or "veil")
|
|
||||||
end
|
|
||||||
local bar = opts.bar ~= false and ui.rect(node, 0, 0, 3, h, "cyan") or nil
|
|
||||||
local ease = anim.fn.out
|
|
||||||
local function step(k)
|
|
||||||
local t = k * M.ENTER
|
|
||||||
local a = math.min(1, t / (M.ENTER * 0.4)) -- 0..40%: in
|
|
||||||
local e = ease(a)
|
|
||||||
-- The wash: .35 -> .2 (40%) -> the row's fill.
|
|
||||||
local wash
|
|
||||||
if a < 1 then
|
|
||||||
wash = vmath.lerp(e, ui.color("cyan", 0.35), ui.color("cyan", 0.2))
|
|
||||||
else
|
|
||||||
wash = vmath.lerp(ease((t - M.ENTER * 0.4) / (M.ENTER * 0.6)), ui.color("cyan", 0.2), base)
|
|
||||||
end
|
|
||||||
gui.set_color(node, wash)
|
|
||||||
if not anim.reduced then
|
|
||||||
gui.set_position(slide, vmath.vector3(p0.x + 28 * (1 - e), p0.y, p0.z))
|
|
||||||
end
|
|
||||||
if opts.fade then
|
|
||||||
opts.fade(e)
|
|
||||||
elseif veil then
|
|
||||||
anim.set(veil, "color.w", 1 - e)
|
|
||||||
end
|
|
||||||
if bar then
|
|
||||||
local b = k < 0.5 and 1 or 1 - ease((k - 0.5) / 0.5)
|
|
||||||
gui.set_color(bar, vmath.vector4(cyan.x, cyan.y, cyan.z, b))
|
|
||||||
gui.set_size(bar, vmath.vector3(3 * b, h, 0))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
step(age / M.ENTER)
|
|
||||||
anim.tween(node, M.ENTER, step, -age, function()
|
|
||||||
pcall(gui.set_color, node, base)
|
|
||||||
pcall(gui.set_position, slide, p0)
|
|
||||||
if veil then
|
|
||||||
pcall(gui.delete_node, veil)
|
|
||||||
end
|
|
||||||
if bar then
|
|
||||||
pcall(gui.delete_node, bar)
|
|
||||||
end
|
|
||||||
if opts.fade then
|
|
||||||
pcall(opts.fade, 1)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
-- A vertically scrolling area (Druid scroll in a stencil-clipped view) for content taller
|
|
||||||
-- than its panel: cards in the Store, Equipment and Advisors, the drill panel.
|
|
||||||
--
|
|
||||||
-- local area = scroll.area(self, parent, x, y, w, h, "store")
|
|
||||||
-- ... build into area.content (top-left based, like any parent) ...
|
|
||||||
-- area:finish(content_height)
|
|
||||||
--
|
|
||||||
-- A section built again makes its scroll area again, so the offset is kept per key in
|
|
||||||
-- self.scroll_pos and restored on the next build. Buttons inside take the view as their
|
|
||||||
-- click zone (Druid does that for nodes under a stencil), so hidden parts can't be
|
|
||||||
-- clicked. Mouse wheel and drag scroll; a thin bar on the right shows where you are; its
|
|
||||||
-- thumb lights up under the mouse (the web's scrollbar hover). Areas register on
|
|
||||||
-- g.scroll_areas (the test hook's scroll_to brings an element into view).
|
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
local blocks = require("modules.blocks")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
local WHEEL = 48 -- pixels per wheel step
|
|
||||||
local BAR_W = 3
|
|
||||||
|
|
||||||
local function vec(x, y)
|
|
||||||
return vmath.vector3(x, y, 0)
|
|
||||||
end
|
|
||||||
|
|
||||||
local Area = {}
|
|
||||||
Area.__index = Area
|
|
||||||
|
|
||||||
function M.area(self, parent, x, y, w, h, key)
|
|
||||||
local view = ui.rect(parent, x, y, w, h, "text")
|
|
||||||
gui.set_clipping_mode(view, gui.CLIPPING_MODE_STENCIL)
|
|
||||||
gui.set_clipping_visible(view, false)
|
|
||||||
local content = ui.rect(view, 0, 0, w, h, "none")
|
|
||||||
self.scroll_pos = self.scroll_pos or {}
|
|
||||||
return setmetatable({ self = self, parent = parent, view = view, content = content,
|
|
||||||
x = x, y = y, w = w, h = h, key = key }, Area)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Call once the content is built: content_h is its height. Returns the Druid scroll (nil
|
|
||||||
-- when everything fits).
|
|
||||||
function Area:finish(content_h)
|
|
||||||
local self_, w, h = self.self, self.w, self.h
|
|
||||||
content_h = math.max(h, math.ceil(content_h))
|
|
||||||
gui.set_size(self.content, vec(w, content_h))
|
|
||||||
local max = content_h - h
|
|
||||||
if max <= 0 then
|
|
||||||
self_.scroll_pos[self.key] = 0
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
-- An eased key scroll that a rebuild cut short (core/keys) lands where it was going.
|
|
||||||
local goal = self_.scroll_goal
|
|
||||||
if goal and goal.key == self.key and socket.gettime() - goal.t < 1 then
|
|
||||||
self_.scroll_pos[self.key] = goal.y
|
|
||||||
self_.scroll_goal = nil
|
|
||||||
end
|
|
||||||
local y0 = math.max(0, math.min(self_.scroll_pos[self.key] or 0, max))
|
|
||||||
gui.set_position(self.content, vec(0, y0))
|
|
||||||
|
|
||||||
-- The bar sits outside the clipped view (on the parent), at the view's right edge.
|
|
||||||
local track_h = h - 8
|
|
||||||
local thumb_h = math.max(28, math.floor(track_h * h / content_h))
|
|
||||||
ui.rect(self.parent, self.x + w - BAR_W, self.y + 4, 1, track_h, "line")
|
|
||||||
local thumb = ui.rect(self.parent, self.x + w - BAR_W - 1, self.y + 4, BAR_W, thumb_h, "cyan", 0.55)
|
|
||||||
local function place(pos_y)
|
|
||||||
local f = math.max(0, math.min(1, pos_y / max))
|
|
||||||
gui.set_position(thumb, vec(self.x + w - BAR_W - 1, -(self.y + 4 + (track_h - thumb_h) * f)))
|
|
||||||
end
|
|
||||||
place(y0)
|
|
||||||
-- Hover: a wider invisible strip over the bar lights the thumb.
|
|
||||||
local hot = ui.rect(self.parent, self.x + w - 10, self.y + 4, 10, track_h, "none")
|
|
||||||
local hover = self_.druid:new_hover(hot)
|
|
||||||
hover.on_mouse_hover:subscribe(function(_, on)
|
|
||||||
gui.cancel_animations(thumb, "color")
|
|
||||||
gui.animate(thumb, "color", ui.color("cyan", on and 1 or 0.55), gui.EASING_OUTQUAD, 0.12)
|
|
||||||
gui.animate(thumb, "size.x", on and BAR_W + 2 or BAR_W, gui.EASING_OUTQUAD, 0.12)
|
|
||||||
end)
|
|
||||||
local s = self_.druid:new_scroll(self.view, self.content)
|
|
||||||
blocks.push(self_, "scroll_areas", { view = self.view, content = self.content, key = self.key, max = max, h = h, scroll = s })
|
|
||||||
|
|
||||||
s:set_horizontal_scroll(false)
|
|
||||||
s:set_extra_stretch_size(0)
|
|
||||||
s.style.WHEEL_SCROLL_SPEED = WHEEL
|
|
||||||
s.on_scroll:subscribe(function(_, pos)
|
|
||||||
if self_.tearing_down then
|
|
||||||
return -- (a rebuild dropping the old scroll: keep the offset)
|
|
||||||
end
|
|
||||||
self_.scroll_pos[self.key] = pos.y
|
|
||||||
place(pos.y)
|
|
||||||
end)
|
|
||||||
return s
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
-- Slider (the web's input type=range): a thin track, its filled part and a knob. Press
|
|
||||||
-- anywhere on the track's row to jump there, drag to move; the value follows the pointer
|
|
||||||
-- exactly (screen position mapped into the track, not summed deltas).
|
|
||||||
--
|
|
||||||
-- local s = slider.new(g, parent, x, y, w, h, { min, max, step, value, id, zone,
|
|
||||||
-- on_change = fn(v), on_release = fn(v) })
|
|
||||||
-- s:set(v) -- move it without on_change (another control changed the value)
|
|
||||||
--
|
|
||||||
-- Built on Druid's slider (input priority high, so a scroll area under it doesn't take
|
|
||||||
-- the drag); its input is replaced by the pointer mapping above. zone: a node the press
|
|
||||||
-- must be inside (a scroll area's view: hidden rows can't be grabbed). on_change gets
|
|
||||||
-- the value snapped to step; on_release when the drag ends. Test id: opts.id (kind
|
|
||||||
-- "slider", no keyboard focus: the number field beside it is the keyboard's way in).
|
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
local test = require("modules.test")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
M.__index = M
|
|
||||||
|
|
||||||
local TOUCH = hash("touch")
|
|
||||||
local KNOB_W, KNOB_H, TRACK_H = 10, 16, 4
|
|
||||||
|
|
||||||
local function vec(x, y)
|
|
||||||
return vmath.vector3(x, y, 0)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function snap(v, o)
|
|
||||||
local n = math.floor((v - o.min) / o.step + 0.5)
|
|
||||||
v = o.min + n * o.step
|
|
||||||
-- (decimal steps: drop float noise like 0.30000000000000004)
|
|
||||||
v = tonumber(string.format("%.6f", v))
|
|
||||||
return math.max(o.min, math.min(o.max, v))
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.new(g, parent, x, y, w, h, opts)
|
|
||||||
local self = setmetatable({ opts = opts, w = w }, M)
|
|
||||||
local hit = ui.rect(parent, x, y, w, h, "none")
|
|
||||||
ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), w, TRACK_H, "line_hi")
|
|
||||||
self.fill = ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), 0, TRACK_H, "cyan", 0.7)
|
|
||||||
local knob = ui.rect(hit, 0, 0, KNOB_W, KNOB_H, "cyan")
|
|
||||||
gui.set_pivot(knob, gui.PIVOT_CENTER)
|
|
||||||
gui.set_position(knob, vec(0, -h / 2))
|
|
||||||
self.knob = knob
|
|
||||||
local s = g.druid:new_slider(knob, vec(w, -h / 2), function(_, f)
|
|
||||||
local v = snap(opts.min + f * (opts.max - opts.min), opts)
|
|
||||||
self:_fill(f)
|
|
||||||
if v ~= self.value then
|
|
||||||
self.value = v
|
|
||||||
if opts.on_change then
|
|
||||||
opts.on_change(v)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
self.s = s
|
|
||||||
local zone = opts.zone
|
|
||||||
s.on_input = function(sl, action_id, action)
|
|
||||||
if action_id ~= TOUCH or not action.screen_x then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
if not sl.is_drag then
|
|
||||||
if not (action.pressed and gui.pick_node(hit, action.x, action.y)
|
|
||||||
and (not zone or gui.pick_node(zone, action.x, action.y))) then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
sl.is_drag = true
|
|
||||||
end
|
|
||||||
local p = gui.screen_to_local(knob, vec(action.screen_x, action.screen_y))
|
|
||||||
local f = math.max(0, math.min(1, p.x / w))
|
|
||||||
if f ~= sl.value then
|
|
||||||
sl:set(f)
|
|
||||||
end
|
|
||||||
if action.released then
|
|
||||||
sl.is_drag = false
|
|
||||||
if opts.on_release then
|
|
||||||
opts.on_release(self.value)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
self:set(opts.value)
|
|
||||||
test.register(opts.id, hit, "slider", opts.label, zone, true)
|
|
||||||
return self
|
|
||||||
end
|
|
||||||
|
|
||||||
function M:_fill(f)
|
|
||||||
gui.set_size(self.fill, vec(math.floor(self.w * f + 0.5), TRACK_H))
|
|
||||||
end
|
|
||||||
|
|
||||||
function M:set(v)
|
|
||||||
local o = self.opts
|
|
||||||
v = math.max(o.min, math.min(o.max, tonumber(v) or o.min))
|
|
||||||
self.value = v
|
|
||||||
local f = o.max > o.min and (v - o.min) / (o.max - o.min) or 0
|
|
||||||
self.s:set(f, true)
|
|
||||||
self:_fill(f)
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
-- Stat lines: a cap label with its value.
|
|
||||||
--
|
|
||||||
-- stat.inline(parent, "Yield", "+3", x, y, color) "YIELD +3" (y the middle); returns
|
|
||||||
-- its width (cards.stat)
|
|
||||||
-- stat.row(parent, "Supply left", "75", x, y, w, opts) label left, value right across
|
|
||||||
-- w (y the top); returns the y under
|
|
||||||
-- it (H.fact). opts: credits (₥ before
|
|
||||||
-- the value), suffix, color
|
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
function M.inline(parent, label, value, x, y, color)
|
|
||||||
local l = ui.text(parent, label, "cap", x, y)
|
|
||||||
local lw = ui.text_width(l)
|
|
||||||
local v = ui.text(parent, value, { font = "mono", px = 14, color = color or "text" }, x + lw + 6, y)
|
|
||||||
return lw + 6 + ui.text_width(v)
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.row(parent, label, value, x, y, w, opts)
|
|
||||||
ui.text(parent, label, "cap", x, y + 8)
|
|
||||||
if opts and opts.credits then
|
|
||||||
ui.credits(parent, value, x + w, y + 8, { pivot = gui.PIVOT_E, suffix = opts.suffix })
|
|
||||||
else
|
|
||||||
ui.text(parent, value, { font = "mono", px = 13, color = opts and opts.color or "text" }, x + w, y + 8, gui.PIVOT_E)
|
|
||||||
end
|
|
||||||
return y + 20
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
-- Tabs: the web's nav tabs and their sliding ink (tabs.js, app.css "tab ink"). A strip's
|
|
||||||
-- ink is a highlight (transparent to cyan .1, top to bottom) with a glowing 2 px
|
|
||||||
-- underline under the active tab; when the active tab changes it slides from the old
|
|
||||||
-- one (220 ms, a slight overshoot). Its spot is kept per strip key on g (g.inks), so it
|
|
||||||
-- slides across the rebuild a navigation causes; a layout resize makes it jump.
|
|
||||||
--
|
|
||||||
-- local ink = tabs.ink(g, parent, "nav") -- before the tabs (it draws under them)
|
|
||||||
-- ... place the tabs ...
|
|
||||||
-- tabs.tab(g, root, text_node, { active = true, on_click = fn, id = "tab:market" })
|
|
||||||
-- ink:to(x, y, w, h) -- the active tab's box in parent
|
|
||||||
-- ink:none() -- no active tab (the ink goes)
|
|
||||||
--
|
|
||||||
-- tabs.tab: hover lights the label (nav -> text, eased), a click runs on_click; the label
|
|
||||||
-- keeps opts.color (default "nav", active "cyan").
|
|
||||||
--
|
|
||||||
-- A tab revealed after the page loaded (onboarding) enters once (app.css tab-reveal, 0.7 s
|
|
||||||
-- overshoot): it fades in, drops 8 px into place and grows from .9 about its middle, out
|
|
||||||
-- of a soft glow (the web's blur); kept by name and time on g across rebuilds.
|
|
||||||
-- tabs.revealed(g, "nav", names) -- once per build: which names are new
|
|
||||||
-- tabs.reveal(g, name, root, text, w, h) -- play / pick up its entrance
|
|
||||||
|
|
||||||
local ui = require("modules.ui")
|
|
||||||
local anim = require("modules.anim")
|
|
||||||
local test = require("modules.test")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
local SLIDE = 0.22
|
|
||||||
local REVEAL = 0.7
|
|
||||||
|
|
||||||
local function vec(x, y)
|
|
||||||
return vmath.vector3(x, y, 0)
|
|
||||||
end
|
|
||||||
|
|
||||||
local Ink = {}
|
|
||||||
Ink.__index = Ink
|
|
||||||
|
|
||||||
function M.ink(g, parent, key)
|
|
||||||
g.inks = g.inks or {}
|
|
||||||
local root = anim.group(parent, 0, 0)
|
|
||||||
-- Highlight: the fade texture flipped (transparent at the top).
|
|
||||||
local hl = gui.new_box_node(vec(0, 0), vec(1, 1))
|
|
||||||
gui.set_parent(hl, root, false)
|
|
||||||
gui.set_texture(hl, "ui")
|
|
||||||
gui.play_flipbook(hl, "fade")
|
|
||||||
gui.set_pivot(hl, gui.PIVOT_CENTER)
|
|
||||||
gui.set_rotation(hl, vmath.vector3(0, 0, 180))
|
|
||||||
gui.set_color(hl, ui.color("cyan", 0.1))
|
|
||||||
local glow = gui.new_box_node(vec(0, 0), vec(1, 1))
|
|
||||||
gui.set_parent(glow, root, false)
|
|
||||||
gui.set_texture(glow, "ui")
|
|
||||||
gui.play_flipbook(glow, "glow")
|
|
||||||
gui.set_pivot(glow, gui.PIVOT_CENTER)
|
|
||||||
gui.set_color(glow, ui.color("cyan", 0.45))
|
|
||||||
local line = ui.rect(root, 0, 0, 1, 2, "cyan")
|
|
||||||
gui.set_enabled(root, false)
|
|
||||||
return setmetatable({ g = g, key = key, root = root, hl = hl, glow = glow, line = line }, Ink)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function place(self, r)
|
|
||||||
gui.set_position(self.root, vec(r.x, -r.y))
|
|
||||||
gui.set_size(self.hl, vec(r.w, r.h))
|
|
||||||
gui.set_position(self.hl, vec(r.w / 2, -r.h / 2))
|
|
||||||
gui.set_size(self.glow, vec(r.w + 16, 18))
|
|
||||||
gui.set_position(self.glow, vec(r.w / 2, -(r.h - 1)))
|
|
||||||
gui.set_size(self.line, vec(r.w, 2))
|
|
||||||
gui.set_position(self.line, vec(0, -(r.h - 2)))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Show the ink on the active tab's box (x, y, w, h in the strip's parent).
|
|
||||||
function Ink:to(x, y, w, h)
|
|
||||||
local g = self.g
|
|
||||||
local to = { x = x, y = y, w = w, h = h, layout = g.size and (g.size[1] .. "x" .. g.size[2]) }
|
|
||||||
local from = g.inks[self.key]
|
|
||||||
g.inks[self.key] = to
|
|
||||||
gui.set_enabled(self.root, true)
|
|
||||||
if not from or from.layout ~= to.layout or anim.reduced
|
|
||||||
or (from.x == x and from.y == y and from.w == w and from.h == h) then
|
|
||||||
place(self, to)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
-- Slide: from the old spot to the new one, a frame-driven tween (box sizes and
|
|
||||||
-- positions together).
|
|
||||||
place(self, from)
|
|
||||||
local ease = anim.fn.ink
|
|
||||||
anim.tween(self.root, SLIDE, function(k)
|
|
||||||
local e = ease(k)
|
|
||||||
place(self, { x = from.x + (x - from.x) * e, y = from.y + (y - from.y) * e,
|
|
||||||
w = from.w + (w - from.w) * e, h = from.h + (h - from.h) * e })
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- No active tab: the ink goes (and comes back without a slide).
|
|
||||||
function Ink:none()
|
|
||||||
self.g.inks[self.key] = nil
|
|
||||||
gui.set_enabled(self.root, false)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Names not shown in the last build of this strip start their entrance now (none the
|
|
||||||
-- first time: what's there on load doesn't enter).
|
|
||||||
function M.revealed(g, key, names)
|
|
||||||
g.tabs_known = g.tabs_known or {}
|
|
||||||
g.tab_reveal = g.tab_reveal or {}
|
|
||||||
local known = g.tabs_known[key]
|
|
||||||
local now = socket.gettime()
|
|
||||||
local set = {}
|
|
||||||
for _, n in ipairs(names) do
|
|
||||||
set[n] = true
|
|
||||||
if known and not known[n] then
|
|
||||||
g.tab_reveal[key .. ":" .. n] = now
|
|
||||||
end
|
|
||||||
end
|
|
||||||
g.tabs_known[key] = set
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The entrance of a revealed tab (root: pivot NW, w x h; text: its label), if it is
|
|
||||||
-- still playing. extra: more nodes that fade in with the label (a button's edge, price).
|
|
||||||
function M.reveal(g, key, name, root, text, w, h, extra)
|
|
||||||
local t0 = g.tab_reveal and g.tab_reveal[key .. ":" .. name]
|
|
||||||
if not t0 then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local age = socket.gettime() - t0
|
|
||||||
if age >= REVEAL then
|
|
||||||
g.tab_reveal[key .. ":" .. name] = nil
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local p, s = gui.get_position(root), gui.get_scale(root)
|
|
||||||
local fill, tc = gui.get_color(root), gui.get_color(text)
|
|
||||||
local glow = gui.new_box_node(vec(w / 2, -h / 2), vec(w + 20, h))
|
|
||||||
local more = {}
|
|
||||||
for _, n in ipairs(extra or {}) do
|
|
||||||
more[#more + 1] = { n, gui.get_color(n) }
|
|
||||||
end
|
|
||||||
gui.set_parent(glow, root, false)
|
|
||||||
gui.set_texture(glow, "ui")
|
|
||||||
gui.play_flipbook(glow, "glow")
|
|
||||||
gui.move_below(glow, text)
|
|
||||||
local function step(k)
|
|
||||||
local a = anim.fn.out(math.min(1, k / 0.5)) -- (opacity: most of it early)
|
|
||||||
for _, m in ipairs(more) do
|
|
||||||
local c = m[2]
|
|
||||||
gui.set_color(m[1], vmath.vector4(c.x, c.y, c.z, c.w * a))
|
|
||||||
end
|
|
||||||
if anim.reduced then
|
|
||||||
gui.set_color(text, vmath.vector4(tc.x, tc.y, tc.z, tc.w * a))
|
|
||||||
gui.set_color(root, vmath.vector4(fill.x, fill.y, fill.z, fill.w * a))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local e = anim.fn.overshoot(k)
|
|
||||||
anim.scale_about(root, vmath.vector3(p.x, p.y + 8 * (1 - e), p.z), s, w / 2, -h / 2, 0.9 + 0.1 * e)
|
|
||||||
gui.set_color(text, vmath.vector4(tc.x, tc.y, tc.z, tc.w * a))
|
|
||||||
gui.set_color(root, vmath.vector4(fill.x, fill.y, fill.z, fill.w * a))
|
|
||||||
gui.set_color(glow, ui.color("cyan_hi", 0.35 * a * (1 - k)))
|
|
||||||
end
|
|
||||||
step(age / REVEAL)
|
|
||||||
anim.tween(root, REVEAL, step, -age, function()
|
|
||||||
pcall(gui.set_position, root, p)
|
|
||||||
pcall(gui.set_scale, root, s)
|
|
||||||
pcall(gui.set_color, root, fill)
|
|
||||||
pcall(anim.set, text, "color.w", tc.w)
|
|
||||||
for _, m in ipairs(more) do
|
|
||||||
pcall(gui.set_color, m[1], m[2])
|
|
||||||
end
|
|
||||||
pcall(gui.delete_node, glow)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A tab's behavior: hover lights its label, a click runs opts.on_click. opts: active,
|
|
||||||
-- color (the label's color when not hovered), on_click, id (test id), label.
|
|
||||||
function M.tab(g, root, text, opts)
|
|
||||||
opts = opts or {}
|
|
||||||
local base = ui.color(opts.color or (opts.active and "cyan" or "nav"))
|
|
||||||
local lit = ui.color(opts.active and "cyan" or "text")
|
|
||||||
local b = g.druid:new_button(root, function()
|
|
||||||
if opts.on_click then
|
|
||||||
opts.on_click()
|
|
||||||
end
|
|
||||||
end):set_animations_disabled()
|
|
||||||
b.hover.on_mouse_hover:subscribe(function(_, on)
|
|
||||||
gui.cancel_animations(text, "color")
|
|
||||||
local c = on and lit or base
|
|
||||||
if anim.reduced or require("components.button").instant then
|
|
||||||
gui.set_color(text, c)
|
|
||||||
else
|
|
||||||
gui.animate(text, "color", c, gui.EASING_OUTQUAD, 0.12)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
test.register(opts.id, root, "tab", opts.label or gui.get_text(text))
|
|
||||||
return b
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
[project]
|
|
||||||
title = MoonMiners
|
|
||||||
version = 0.1.0
|
|
||||||
dependencies#0 = https://github.com/Insality/defold-event/archive/refs/tags/21.zip
|
|
||||||
dependencies#1 = https://github.com/Insality/druid/archive/refs/tags/1.4.1.zip
|
|
||||||
dependencies#2 = https://github.com/britzl/monarch/archive/refs/tags/6.0.3.zip
|
|
||||||
|
|
||||||
[bootstrap]
|
|
||||||
main_collection = /main/main.collectionc
|
|
||||||
render = /render/mm.renderc
|
|
||||||
|
|
||||||
[display]
|
|
||||||
width = 1280
|
|
||||||
height = 800
|
|
||||||
high_dpi = 1
|
|
||||||
update_frequency = 60
|
|
||||||
vsync = 1
|
|
||||||
|
|
||||||
[render]
|
|
||||||
clear_color_red = 0.0157
|
|
||||||
clear_color_green = 0.0392
|
|
||||||
clear_color_blue = 0.0863
|
|
||||||
clear_color_alpha = 1
|
|
||||||
|
|
||||||
[input]
|
|
||||||
game_binding = /builtins/input/all.input_bindingc
|
|
||||||
|
|
||||||
[script]
|
|
||||||
shared_state = 1
|
|
||||||
|
|
||||||
[graphics]
|
|
||||||
max_font_batches = 1024
|
|
||||||
default_texture_min_filter = linear
|
|
||||||
default_texture_mag_filter = linear
|
|
||||||
max_characters = 32768
|
|
||||||
|
|
||||||
[gui]
|
|
||||||
max_count = 2048
|
|
||||||
|
|
||||||
[event]
|
|
||||||
use_xpcall = 1
|
|
||||||
|
|
||||||
[druid]
|
|
||||||
no_auto_input = 1
|
|
||||||
|
|
||||||
[mm]
|
|
||||||
api_base = http://localhost:3000
|
|
||||||
quit_after = 0
|
|
||||||
popup = 0
|
|
||||||
window =
|
|
||||||
ui_scale =
|
|
||||||
moon_id =
|
|
||||||
moon_spin = 1
|
|
||||||
player =
|
|
||||||
view =
|
|
||||||
|
|
||||||
[sound]
|
|
||||||
max_component_count = 64
|
|
||||||
|
|
||||||
[html5]
|
|
||||||
htmlfile = /web/index.html
|
|
||||||
scale_mode = stretch
|
|
||||||
show_fullscreen_button = 0
|
|
||||||
show_made_with_defold = 0
|
|
||||||
show_console_banner = 0
|
|
||||||
heap_size = 256
|
|
||||||