Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e267992615 | ||
|
|
fa5fc46cb8 | ||
|
|
7af1c5a821 | ||
|
|
fcec292660 |
No files matched your search
@@ -7,4 +7,3 @@ node_modules
|
||||
.DS_Store
|
||||
web/static/css/app.css
|
||||
art
|
||||
web/static/app
|
||||
@@ -1,3 +1,8 @@
|
||||
# Build output
|
||||
/bin/
|
||||
/moonminers
|
||||
/web/static/css/app.css
|
||||
|
||||
# Local env files (never commit)
|
||||
.env
|
||||
.env*.local
|
||||
@@ -7,12 +12,14 @@
|
||||
.DS_Store
|
||||
|
||||
# Personal files
|
||||
/sql/create-users.sql
|
||||
/.dir-locals.el
|
||||
sql/queries.sql
|
||||
/project.todo
|
||||
|
||||
# Blender files
|
||||
# Blender files (generated by art/equipment scripts)
|
||||
*.blend
|
||||
*.blend1
|
||||
|
||||
# Agent worktree state (scripts/wt)
|
||||
/.wt/
|
||||
/art/equipment/build/
|
||||
/art/advisors/build/
|
||||
/art/attachments/build/
|
||||
@@ -1,46 +1,193 @@
|
||||
# AGENTS.md — Moon Miners
|
||||
|
||||
## What this is
|
||||
Moon Miners is an 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
|
||||
moons ("staking sources") with resource wells, buy and upgrade mining tools (store items)
|
||||
and stake them on wells. When a stake finishes (drill cycles are seconds:
|
||||
`completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
|
||||
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined
|
||||
items (Refinery section: timed batches in slots; refined items sell for more and are parts
|
||||
for higher tool upgrades). Drill attachments (bought in the Store, equipped on a drill from
|
||||
its panel in Equipment) give it a modifier that makes it faster or bigger; advisors
|
||||
(Advisors section, one per seat) boost drill speed, yield, sell prices or the refinery for
|
||||
the whole account. There is also a leaderboard (Leaderboard section: top miners by credits,
|
||||
banned players left out).
|
||||
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. Players own moons ("staking sources") with
|
||||
resource wells, buy and upgrade mining tools (store items) and stake them on wells. When a
|
||||
stake finishes (drill cycles are seconds: `completionTimeInSecs`, `duration_in_secs`) they
|
||||
claim resources (Sollux, Shadowstone, Azurium, Novafor, Nebulance), which
|
||||
they can sell for credits (₥) or refine into refined items (Refinery section: timed batches
|
||||
in slots; refined items sell for more and are parts for higher tool upgrades). Drill
|
||||
attachments (bought in the Store, equipped on a drill from its panel in Equipment) give it
|
||||
a modifier that makes it faster or bigger; advisors (Advisors section, one per seat) boost drill speed, yield, sell prices or the
|
||||
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`).
|
||||
|
||||
## How the parts fit
|
||||
- `server/`: the Go server (game rules, Postgres, admin pages) and the web client, which it
|
||||
renders as HTML (htmx, three.js moon). The server is the only authority on game state.
|
||||
Details: `server/AGENTS.md`.
|
||||
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's
|
||||
JSON API (`/api/state`, `/api/act/<action>`, documented in `server/api.md`) and shows
|
||||
what it sends; its moon generator is a port of `server/web/static/js/terrain.js`.
|
||||
Details: `client/AGENTS.md`.
|
||||
- Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
|
||||
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
|
||||
## Stack
|
||||
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
|
||||
package at the repo root; templates, static files and SQL are embedded with `go:embed`.
|
||||
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
|
||||
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
|
||||
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
|
||||
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
|
||||
The viewport in the aside shows the selected moon, generated procedurally from its id
|
||||
(`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
|
||||
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
|
||||
- Postgres 16, stock image. No functions or extensions in the database.
|
||||
|
||||
## Workspace
|
||||
- The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace,
|
||||
outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`).
|
||||
- Agent worktrees go in the workspace, next to `game/`. Make them with `scripts/wt` (from
|
||||
the workspace: `game/scripts/wt up <task>`), not by hand: it makes `../wt-<task>` on
|
||||
branch `<task>`, copies the Defold build cache (no remote engine build) and `.env`,
|
||||
creates and migrates the database `mm_<task>`, and starts a server (MM_DEV=1) on a free
|
||||
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
|
||||
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
|
||||
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
|
||||
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
||||
commit it.
|
||||
## Layout
|
||||
- The repo is `server/` in the `MoonMiners/` workspace; agent worktrees go next to it
|
||||
(`git worktree add ../wt-<task> -b <task>`), a Defold client later in `../client`.
|
||||
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
|
||||
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
|
||||
- `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it
|
||||
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
|
||||
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
|
||||
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
|
||||
owned for the moon price and well scale, and can't be sold.
|
||||
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
|
||||
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
|
||||
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
|
||||
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
|
||||
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
|
||||
action and responds the same way.
|
||||
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
|
||||
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
|
||||
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422).
|
||||
Player id from `X-MM-User` or the cookie, else a new player (id echoed back).
|
||||
`api_sections.go`: every section's block (equipment and drill panel, store, refinery,
|
||||
advisors, leaderboard, help, new-moon teaser, reveal and buy modal in act responses, moon
|
||||
sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
|
||||
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or
|
||||
`include=`; reasons are codes with the web's text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
|
||||
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
|
||||
for every test view.
|
||||
- `config/game.json`: the game config (starting credits/resources/tools, moon price, well
|
||||
supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a
|
||||
name, a placeholder description and resource `affinities`, refinery), embedded.
|
||||
`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)
|
||||
- The server is the authority: clients show the state it sends and send actions; no game
|
||||
rules on the client side beyond what `server/api.md` "Prediction" allows.
|
||||
- Work in one part at a time: a client task doesn't change `server/`, and the other way
|
||||
round, unless the task says so.
|
||||
## Commands
|
||||
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind watching.
|
||||
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`,
|
||||
moon traits check), `make migrate`, `make load-config`. `go run . sim` prints the economy analysis.
|
||||
- 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.
|
||||
@@ -5,14 +5,10 @@ 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
|
||||
ARG ESBUILD_VERSION=0.28.2
|
||||
RUN curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-${ESBUILD_VERSION}.tgz \
|
||||
| tar xz -C /usr/local/bin --strip-components=2 package/bin/esbuild
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \
|
||||
&& make app ESBUILD=/usr/local/bin/esbuild \
|
||||
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
|
||||
|
||||
FROM scratch
|
||||
@@ -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,38 +1,252 @@
|
||||
# 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, JSON API (`server/api.md`) and the web client (htmx + three.js).
|
||||
- `client/`: Defold client, for desktop and the browser (WebAssembly).
|
||||
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
|
||||
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 and web client
|
||||
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
|
||||
`server/README.md`).
|
||||
## Requirements
|
||||
|
||||
```bash
|
||||
podman-compose up -d db # or: docker compose up -d db
|
||||
cd server
|
||||
make migrate
|
||||
make dev # http://localhost:3000
|
||||
- Go 1.25+
|
||||
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
|
||||
- Node.js, only for `make check` (syntax-checks the JS)
|
||||
|
||||
`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, run `make migrate` again before starting the server: it applies new
|
||||
migrations and syncs the tools with `config/game.json` (the server alone doesn't).
|
||||
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates
|
||||
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated
|
||||
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 Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor
|
||||
is optional: https://defold.com/download/
|
||||
1. Create `.env` as above.
|
||||
2. Start Postgres and set up the database (roles, schema, migrations, store items from
|
||||
`config/game.json`):
|
||||
|
||||
With the server running:
|
||||
```bash
|
||||
podman-compose up -d db # or: docker compose up -d db
|
||||
make migrate
|
||||
```
|
||||
|
||||
```bash
|
||||
cd client
|
||||
make run # desktop build, plays against http://localhost:3000
|
||||
make web && make serve-web # browser build: open http://localhost:8080
|
||||
```
|
||||
3. Run the server and open http://localhost:3000:
|
||||
|
||||
## 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 ""
|
||||
}
|
||||
@@ -34,22 +34,19 @@ type APIState struct {
|
||||
Resources []APIAmount `json:"resources"`
|
||||
Refined []APIAmount `json:"refined"`
|
||||
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"`
|
||||
Shine []string `json:"shine"` // revealed tabs not opened yet
|
||||
Ready int `json:"ready"` // finished drills, all moons
|
||||
Idle int `json:"idle"` // tools not mining
|
||||
NextEnd *int64 `json:"nextEnd"`
|
||||
Moons []APIMoonListItem `json:"moons"` // active moons
|
||||
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first: one page (page=)
|
||||
PastPage APIPastPage `json:"pastPage"`
|
||||
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first
|
||||
Tools []APITool `json:"tools"`
|
||||
Stakes []APIStake `json:"stakes"` // every running or unclaimed drill
|
||||
Moon *APIMoon `json:"moon"` // s=moons with a moon
|
||||
Well *APIWell `json:"well"` // s=moons with a well selected
|
||||
Market *APIMarket `json:"market"` // once the market is unlocked
|
||||
Banner []APIBanner `json:"banner"`
|
||||
Admin bool `json:"admin"` // may use admin-grant
|
||||
|
||||
// Always present (api_sections.go).
|
||||
ShowNav bool `json:"showNav"` // the section tabs show (not while Moons would be the only one)
|
||||
@@ -78,17 +75,6 @@ type APIView struct {
|
||||
Past bool `json:"past"`
|
||||
Pick string `json:"pick"`
|
||||
Drill string `json:"drill"`
|
||||
Page int `json:"page"` // the page of past moons (from 1)
|
||||
Adv string `json:"adv"` // the selected hired advisor (advisors section)
|
||||
Aside bool `json:"aside"` // the web shows the aside (viewport and side panel)
|
||||
}
|
||||
|
||||
// APIPastPage is the page of past moons in past: request others with page=<n>.
|
||||
type APIPastPage struct {
|
||||
Page int `json:"page"` // from 1, most recently finished first
|
||||
Pages int `json:"pages"` // at least 1
|
||||
Total int `json:"total"` // past moons in all
|
||||
PerPage int `json:"perPage"`
|
||||
}
|
||||
|
||||
type APIAmount struct {
|
||||
@@ -116,8 +102,6 @@ type APIMoonListItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
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"`
|
||||
Wells []APIWellSupply `json:"wells"`
|
||||
Drills int `json:"drills"`
|
||||
@@ -147,21 +131,19 @@ type APITool struct {
|
||||
}
|
||||
|
||||
type APIStake struct {
|
||||
ID string `json:"id"`
|
||||
MoonID string `json:"moonId"`
|
||||
WellID string `json:"wellId"` // the first well it mines
|
||||
ToolID string `json:"toolId"`
|
||||
Resource string `json:"resource"`
|
||||
Amount float64 `json:"amount"`
|
||||
Reach int `json:"reach"`
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
Secs int `json:"durationSecs"`
|
||||
Ready bool `json:"ready"`
|
||||
Take float64 `json:"take"` // what claiming it would get now
|
||||
// The web's words for its reach: "Mines 3 wells", "Strips the whole moon", "" for one.
|
||||
ReachText string `json:"reachText"`
|
||||
Covers []string `json:"covers,omitempty"` // multi-well drills: the wells it mines
|
||||
ID string `json:"id"`
|
||||
MoonID string `json:"moonId"`
|
||||
WellID string `json:"wellId"` // the first well it mines
|
||||
ToolID string `json:"toolId"`
|
||||
Resource string `json:"resource"`
|
||||
Amount float64 `json:"amount"`
|
||||
Reach int `json:"reach"`
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
Secs int `json:"durationSecs"`
|
||||
Ready bool `json:"ready"`
|
||||
Take float64 `json:"take"` // what claiming it would get now
|
||||
Covers []string `json:"covers,omitempty"` // multi-well drills: the wells it mines
|
||||
// Moon detail only (projection, claims oldest first): what it gets in all and from
|
||||
// this well, and whether that is its full amount (ok, short, none).
|
||||
Gets *float64 `json:"gets,omitempty"`
|
||||
@@ -183,8 +165,6 @@ type APIMoon struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
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"`
|
||||
Rating *APIRating `json:"rating"`
|
||||
Finished bool `json:"finished"`
|
||||
@@ -206,17 +186,6 @@ type APIWellRow struct {
|
||||
Remain float64 `json:"remain"`
|
||||
DryAfter int `json:"dryAfter"` // the claim that empties it (0: none)
|
||||
DryAt *int64 `json:"dryAt"`
|
||||
// Shared supply, as the web's well note: over (drills want more than it holds), full
|
||||
// (they'll drain it), statusText the note's lead ("Over-drilled — next claims will come
|
||||
// up short", "Fully committed" or ""), noteText the rest ("47 left · 2 committed · 45
|
||||
// free after", "" with no demand).
|
||||
Over bool `json:"over"`
|
||||
Full bool `json:"full"`
|
||||
// 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 {
|
||||
@@ -239,7 +208,6 @@ type APIToolGroup struct {
|
||||
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
|
||||
Free float64 `json:"free"`
|
||||
Short bool `json:"short"`
|
||||
Blocked bool `json:"blocked"` // here is 0: the stake is refused
|
||||
Attachments []string `json:"attachments"`
|
||||
}
|
||||
|
||||
@@ -254,7 +222,7 @@ type APIBetterWell struct {
|
||||
type APIMarket struct {
|
||||
Quotes []APIQuote `json:"quotes"`
|
||||
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
|
||||
Best string `json:"best"` // biggest 24h gain
|
||||
Worst string `json:"worst"` // biggest 24h loss
|
||||
@@ -262,43 +230,27 @@ type APIMarket struct {
|
||||
}
|
||||
|
||||
type APIQuote struct {
|
||||
Item string `json:"item"`
|
||||
Refined bool `json:"refined"`
|
||||
Balance float64 `json:"balance"`
|
||||
Price float64 `json:"price"` // the shared quote: rate × boost, the same for every player
|
||||
Rate float64 `json:"rate"`
|
||||
Boost float64 `json:"boost"`
|
||||
// Personal, on the payout only: the sell bonus (advisors), the selling pressure and its
|
||||
// factor now.
|
||||
Item string `json:"item"`
|
||||
Refined bool `json:"refined"`
|
||||
Balance float64 `json:"balance"`
|
||||
Price float64 `json:"price"` // one unit sells for now: rate × boost × advisor × factor
|
||||
Rate float64 `json:"rate"`
|
||||
Boost float64 `json:"boost"`
|
||||
Advisor float64 `json:"advisor"`
|
||||
Pressure float64 `json:"pressure"`
|
||||
Factor float64 `json:"factor"`
|
||||
Unit float64 `json:"unit"` // what one unit pays this player now: price × advisor × factor
|
||||
Open float64 `json:"open"` // market rate 24h ago
|
||||
Change float64 `json:"change"` // percent over 24h
|
||||
High float64 `json:"high"`
|
||||
Low float64 `json:"low"`
|
||||
Value float64 `json:"value"` // balance × price
|
||||
// For the sell ticket: what a unit pays this player before selling pressure (price ×
|
||||
// advisor), the base price (pressure is counted in base-price credits) and what selling
|
||||
// everything held pays them now.
|
||||
Value float64 `json:"value"`
|
||||
// For the sell ticket: the price before selling pressure, the base price (pressure is
|
||||
// counted in base-price credits) and what selling everything held pays now.
|
||||
Symbol string `json:"symbol"`
|
||||
Unpressed float64 `json:"unpressed"`
|
||||
Base float64 `json:"base"`
|
||||
SellAll float64 `json:"sellAll"`
|
||||
Spark []float64 `json:"spark,omitempty"` // s=market: 24h, every 3rd point (15 minutes) and now
|
||||
// The web's texts: up (change >= 0, green ▲), changeText "+1.25%"/"−0.50%"; pressed
|
||||
// (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).
|
||||
@@ -345,7 +297,7 @@ func apiGear(gear []*Attachment) []string {
|
||||
func apiStake(st StakeRow, projected bool) APIStake {
|
||||
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
|
||||
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
|
||||
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers, ReachText: st.ReachText()}
|
||||
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers}
|
||||
if projected {
|
||||
gets, here := st.Gets, st.Here
|
||||
a.Gets, a.Here, a.State, a.Fate = &gets, &here, st.State, st.Fate()
|
||||
@@ -365,7 +317,7 @@ func apiGroup(g DrillGroup) APIDrillGroup {
|
||||
func apiMoonList(rows []MoonListRow) []APIMoonListItem {
|
||||
out := []APIMoonListItem{}
|
||||
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(),
|
||||
Finished: r.Finished, FinishedAt: unixMS(r.FinishedAt)}
|
||||
for _, w := range r.Wells {
|
||||
@@ -384,13 +336,11 @@ func apiMoonList(rows []MoonListRow) []APIMoonListItem {
|
||||
func apiState(p *Page, extra ...*Page) *APIState {
|
||||
s, v := p.S, p.V
|
||||
a := &APIState{UserID: s.UserID, Now: s.Now.UnixMilli(),
|
||||
View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill,
|
||||
Page: v.Page, Adv: v.Adv, Aside: p.Scene.Select != nil},
|
||||
PastPage: APIPastPage{Page: s.PastPage, Pages: s.pastPages(), Total: s.PastTotal, PerPage: pastPerPage},
|
||||
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(), MoonGrade: s.MoonGrade().Grade,
|
||||
View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill},
|
||||
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
|
||||
Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd),
|
||||
Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{},
|
||||
Banner: []APIBanner{}, Admin: p.Admin}
|
||||
Banner: []APIBanner{}}
|
||||
for _, r := range s.Refined {
|
||||
a.Refined = append(a.Refined, APIAmount{Name: r.Item, Amount: r.Balance})
|
||||
}
|
||||
@@ -428,7 +378,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
|
||||
}
|
||||
|
||||
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{}}
|
||||
if d.Wide != nil {
|
||||
g := apiGroup(*d.Wide)
|
||||
@@ -437,8 +387,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
|
||||
for _, w := range d.Wells {
|
||||
row := APIWellRow{APIWellSupply: APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply},
|
||||
APIDrillGroup: apiGroup(w.DrillGroup), Wide: []string{}, Selected: w.Selected, Redrill: w.Redrill,
|
||||
Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt),
|
||||
Over: w.Over(), Full: w.Full(), FullyCommitted: w.FullyCommitted(), StatusText: w.StatusText(), NoteText: w.NoteText()}
|
||||
Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt)}
|
||||
for _, st := range w.Wide {
|
||||
row.Wide = append(row.Wide, st.ID)
|
||||
}
|
||||
@@ -454,7 +403,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
|
||||
for _, g := range w.Idle {
|
||||
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
|
||||
Tier: g.Tier, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield, Reach: max(1, g.Reach), Here: g.Here,
|
||||
Free: g.Free, Short: g.Short(), Blocked: g.Blocked(), Attachments: apiGear(g.Gear)})
|
||||
Free: g.Free, Short: g.Short(), Attachments: apiGear(g.Gear)})
|
||||
}
|
||||
if b := w.Better; b != nil {
|
||||
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
|
||||
@@ -538,6 +487,7 @@ func (a *APIState) addSection(p *Page) {
|
||||
|
||||
// apiMarket maps the market; full adds the chart and the sparklines.
|
||||
func apiMarket(trade *MarketPage, full bool) *APIMarket {
|
||||
c := cfg()
|
||||
mk := &APIMarket{Quotes: []APIQuote{}, Total: trade.Total, Held: trade.Held}
|
||||
if trade.Selected != nil {
|
||||
mk.Selected = trade.Selected.Resource
|
||||
@@ -550,20 +500,11 @@ func apiMarket(trade *MarketPage, full bool) *APIMarket {
|
||||
}
|
||||
for _, q := range trade.Quotes {
|
||||
aq := APIQuote{Item: q.Resource, Refined: q.Refined, Balance: q.Balance,
|
||||
Price: q.Price, Rate: q.Rate, Boost: q.Boost, Advisor: q.Advisor, Pressure: q.Pressure, Factor: q.Factor, 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,
|
||||
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base(),
|
||||
Up: q.Up(), ChangeText: q.ChangeText(), Pressed: q.Pressed(), Boosted: q.Boosted(), Advised: q.Advised()}
|
||||
if aq.Pressed {
|
||||
aq.PressureText = q.PressureText()
|
||||
}
|
||||
if aq.Boosted {
|
||||
aq.BoostText = q.BoostText()
|
||||
}
|
||||
if aq.Advised {
|
||||
aq.AdvisorText = q.AdvisorText()
|
||||
}
|
||||
aq.SellAll = q.SellAll()
|
||||
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base()}
|
||||
// A sale is priced at the pressure factor averaged over what it adds (sell in game.go).
|
||||
aq.SellAll = q.Balance * aq.Unpressed * c.MarketPressure.Average(q.Pressure, q.Balance*aq.Base)
|
||||
if full {
|
||||
aq.Spark = spark(q.series)
|
||||
}
|
||||
@@ -590,24 +531,9 @@ type APIResponse struct {
|
||||
Reveal *APIReveal `json:"reveal,omitempty"`
|
||||
BuyModal *APIBuyModal `json:"buyModal,omitempty"`
|
||||
ClientSeq any `json:"clientSeq,omitempty"` // the request's clientSeq, echoed (a number if it is an integer)
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Message string `json:"message"`
|
||||
Error bool `json:"error"`
|
||||
@@ -674,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
|
||||
// request's include= and catalog= (see apiStateWith).
|
||||
func (s *server) apiPage(ctx context.Context, userID string, v View, get func(string) string) (*APIState, error) {
|
||||
state, v, err := loadView(ctx, s.db, userID, v)
|
||||
state, err := loadState(ctx, s.db, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, err := s.apiPageFor(ctx, state, v)
|
||||
page, err := s.apiPageFor(ctx, state, v.resolve(state))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -699,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.
|
||||
func (s *server) apiStateWith(ctx context.Context, page *Page, get func(string) string) (*APIState, error) {
|
||||
page.Banner = s.banner(ctx)
|
||||
page.Admin = s.isAdmin(page.S.UserID)
|
||||
var extra []*Page
|
||||
for _, section := range apiIncludes(get("include")) {
|
||||
if section == page.V.Section {
|
||||
@@ -781,42 +706,14 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
|
||||
apiError(w, http.StatusBadRequest, "Bad request")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
action := r.PathValue("action")
|
||||
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")
|
||||
userID, created := s.apiUser(w, r)
|
||||
if userID == "" {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errForbidden) {
|
||||
apiError(w, http.StatusForbidden, "Missing permission")
|
||||
ctx := r.Context()
|
||||
res, err := s.runAction(ctx, userID, r.PathValue("action"), get)
|
||||
if errors.Is(err, errUnknownAction) {
|
||||
apiError(w, http.StatusNotFound, "Unknown action")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -839,28 +736,19 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
apiServerError(w, err)
|
||||
return
|
||||
}
|
||||
toast := res.toast(state)
|
||||
page, err := s.apiPageFor(ctx, state, v)
|
||||
page, err := s.apiPageFor(ctx, state, res.View.resolve(state))
|
||||
if err != nil {
|
||||
apiServerError(w, err)
|
||||
return
|
||||
}
|
||||
resp := APIResponse{OK: !toast.Error, UserID: userID, New: created, ClientSeq: apiSeq(get("clientSeq")),
|
||||
Toast: &APIToast{Message: toast.Message, Error: toast.Error, Kind: toast.Kind}, SaveCode: saveCode}
|
||||
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}
|
||||
}
|
||||
}
|
||||
Toast: &APIToast{Message: toast.Message, Error: toast.Error, Kind: toast.Kind}}
|
||||
if !toast.Error {
|
||||
if res.BoughtTool != "" {
|
||||
resp.BuyModal = apiBuyModal(state.buySuggestions(res.BoughtTool))
|
||||
@@ -23,34 +23,11 @@ names an existing player, a new one is created (at most 10 per minute per IP, el
|
||||
the `X-MM-User` header, the `mm_user` cookie and `userId`; store it and send it back.
|
||||
Banned players get `403`.
|
||||
|
||||
### Save codes
|
||||
|
||||
A player can get back to its progress from another device (or after the id is lost) with
|
||||
a save code: 16 characters in groups of four, e.g. `D5RN-N5WQ-5TVC-H6V7`, from
|
||||
`ABCDEFGHJKMNPQRSTVWXYZ23456789` (no 0/O, 1/I/L, U). The server keeps only a hash, so the
|
||||
code is shown once, when it is made. One code per player; making a new one turns the old
|
||||
one off.
|
||||
- `GET /api/save-code`: `{ok, userId, saveCode: {madeAt}}`, `madeAt` unix ms of the current
|
||||
code, `0` when the player has none.
|
||||
- `POST /api/act/save-code`: makes a new code; the response (as any action's, `toast.kind`
|
||||
`save-code`) carries `saveCode: {code, madeAt}`.
|
||||
- `POST /api/act/restore` with `code` (case, spaces and dashes don't matter): switches to the
|
||||
code's player, no current player needed. `200` as any action, with the new id in
|
||||
`X-MM-User`, the cookie and `userId` (store it, drop the old state and view: the state
|
||||
is the restored player's first moon), `toast.kind` `restore`. `422` (with an error `toast`, no `state`) "That save code
|
||||
doesn't match any miner", `429` after 5 tries a minute per IP; a banned player's code is a `422` ("That miner is
|
||||
suspended"), not a `403`.
|
||||
- The first successful `sell` of a player without a code returns, once,
|
||||
`notice: {kind: "save-code", text}` beside the toast: an announcement pointing to save
|
||||
codes (a client may word it for where its menu is).
|
||||
|
||||
## GET /api/state
|
||||
|
||||
Query params (all optional, same as the web URL): `s` (section: `moons`, `equipment`,
|
||||
`market`, `store`, `refinery`, `advisors`, `leaderboard`, `help`, `new-moon`), `moon`,
|
||||
`well`, `list=past` (show a mined-out moon), `page` (the page of `past`, from 1: 10 past
|
||||
moons a page, most recently finished first; out-of-range pages are clamped), `pick` (market:
|
||||
selected quote), `drill`, `adv` (advisors: the hired advisor whose panel the web shows).
|
||||
`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=`
|
||||
@@ -74,10 +51,10 @@ Core-loop actions and their params:
|
||||
|
||||
| action | params | notes |
|
||||
|---|---|---|
|
||||
| `buy-moon` | `grade` (optional: `D`, `C`, `B`, `A`, `S`; default `state.moonGrade`, the grade of the newest moon, else the lowest) | view moves to the new moon; an unknown grade is a rule error |
|
||||
| `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used. Refused ("Nothing left to mine on this well") when the drills already on the moon will take all the tool could get (its deploy card's `here` is 0, `blocked`) |
|
||||
| `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`; a tool whose well is drained or has no room left (as `stake`) stays idle |
|
||||
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well, until it has no room left (as `stake`; the rest stay idle, refused if none fits) |
|
||||
| `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:
|
||||
@@ -93,27 +70,19 @@ The other sections' actions:
|
||||
| `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
|
||||
| `collect` | `job` (job id), or none for every finished batch | |
|
||||
| `buy-slot` | – | refinery slot |
|
||||
| `upgrade-batch` | – | the next refinery batch size (every slot) |
|
||||
| `hire` | `advisor` (name) | needs a free seat; takes the lowest one (`freeSeat`) |
|
||||
| `hire` | `advisor` (name) | needs a free seat |
|
||||
| `buy-seat` | – | advisor seat |
|
||||
| `seat-advisor` | `advisor`, `seat` (from 1) | a hired advisor into an empty seat (moves them if seated) |
|
||||
| `unseat-advisor` | `advisor` | off their seat: stays hired, no bonus |
|
||||
| `swap-advisors` | `advisor`, `with` (names) | exchange seats; one of them may be unseated |
|
||||
| `dismiss-advisor` | `advisor` | frees their seat; nothing is refunded (confirm with `dismiss`) |
|
||||
| `sell-moon` | `sell-moon` (moon id) | `moon.sale.canSell`; view moves to another moon |
|
||||
| `new-game` | – | creates a new player and returns its id and state |
|
||||
| `save-code` | – | a new save code (see Save codes), in `saveCode` |
|
||||
| `restore` | `code` | switches to the save code's player (see Save codes) |
|
||||
| `admin-grant` | `grant` (`credits`, a resource or a refined item name), `amount` | admins only (`state.admin`), else `403`; logged as `admin_grant` |
|
||||
|
||||
Responses:
|
||||
- `200` `{ok: true, userId, clientSeq?, toast: {message, error: false, kind}, fx: {kind, finished, credits}, reveal?, buyModal?, saveCode?, notice?, state}`.
|
||||
- `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 or not an admin (`admin-grant`),
|
||||
- `400` bad body, `404` unknown action, `429` too many new players, `403` banned,
|
||||
`500` `{ok: false, error: "Something went wrong"}`.
|
||||
|
||||
## GET /api/moon-settings
|
||||
@@ -129,29 +98,26 @@ session; the web page also reads it once per page load.
|
||||
| key | |
|
||||
|---|---|
|
||||
| `userId`, `now` | |
|
||||
| `view` | `{section, moon, well, past, page, pick, drill, adv, aside}`, resolved (`page` clamped, `adv` only a hired advisor in `s=advisors`); `aside`: the web shows the aside (viewport and side panel): moon view, new-moon teaser, an open drill or advisor panel |
|
||||
| `view` | `{section, moon, well, past, pick, drill}`, resolved |
|
||||
| `credits` | balance |
|
||||
| `admin` | the player is an admin (`ADMIN_USER_IDS`, or everyone with `MM_DEV=1`): may use `admin-grant` |
|
||||
| `resources`, `refined` | `[{name, amount}]` |
|
||||
| `moonPrice` | price of the next moon at `moonGrade` |
|
||||
| `moonGrade` | the grade `buy-moon` gets without a `grade`: the newest moon's, else the lowest |
|
||||
| `moonPrice` | price of the next moon |
|
||||
| `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false |
|
||||
| `shine` | revealed sections not opened yet |
|
||||
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
|
||||
| `pastPage` | `{page, pages, total, perPage}`: the page of past moons in `past` (`total` in all); ask for others with `page=` |
|
||||
| `moons`, `past` | `past` is one page (`pastPage`). `[{id, name, kind, grade, maxSupply, 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"); `grade` its moon grade, `maxSupply` its grade's biggest well (a full supply bar) |
|
||||
| `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, reachText, start, end, durationSecs, ready, take, covers?}`; `reachText` the web's "Mines 3 wells" / "Strips the whole moon" (`""` for one well) |
|
||||
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `fullyCommitted` (`remain` is 0 with supply left: a one-well drill can't be deployed on it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
|
||||
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, blocked, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
|
||||
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, unit, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is the shared quote (`rate` × `boost`, the same for every player; ticker, cards, `value` = balance × price, `total`); personal, on the payout only: `advisor` (sell bonus), `pressure` and its `factor` (the player's recent sales of the item), `unit` = price × advisor × factor (what one unit pays them now), `unpressed` = price × advisor, `sellAll` what selling the whole balance pays them (a sale of n pays n × unpressed × the pressure factor averaged over the n × base it adds, see `rules.pressure`); the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `boostText` "×2" when `boosted` (live event, on the quote), and the ticket's personal lines `advisorText` "+8%" when `advised` ("+8% for you") and `pressureText` "−4%" when `pressed` ("−4% from your recent selling") (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
|
||||
| `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: {bulkLog, 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 selected moon's grade max, or its biggest well if bigger; with no moon selected the default grade's); `wellSupplyMax`, `wellsMin`, `wellsMax` are the default grade's (each moon carries its own `maxSupply`); `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, speed, cycleSecs, 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 at that tier); `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) |
|
||||
| `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`,
|
||||
@@ -170,9 +136,9 @@ short code, listed with each field, and the web's text next to it.
|
||||
drill}
|
||||
```
|
||||
|
||||
- `image`: the tool's art at its tier, like `state.tools`.
|
||||
- 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
|
||||
@@ -212,18 +178,14 @@ would earn with the player's advisors, `bestRate` the same for their best tool (
|
||||
```
|
||||
{slots: [{n, job: {id, slot, item, qty, start, end, ready, progress} | null}], slotCount, maxSlots,
|
||||
nextSlot, nextSlotPrice, canBuySlot, free, ready, maxBatch,
|
||||
nextBatch, nextBatchPrice, canUpgradeBatch, upgradeBatchWhyText,
|
||||
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. `maxBatch` is the most one batch can make for this
|
||||
player (the config's `refinery.maxBatch`, raised by each `upgrade-batch`, for every slot;
|
||||
also in `rules.refinery`); `nextBatch`/`nextBatchPrice` the next upgrade (0 at the top),
|
||||
`upgradeBatchWhyText` why it's disabled ("" when it isn't). Per recipe: `inputs` per unit, `maxQty` what one batch
|
||||
`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` what one unit pays the player now (the market quote's `unit`), `why` `busy` (no
|
||||
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.
|
||||
@@ -231,31 +193,14 @@ inputs"). `buySlot` is the buy-slot dialog (`null` at the cap), `buySlotWhyText`
|
||||
### advisors (`s=advisors`)
|
||||
|
||||
```
|
||||
{seats, hired, benched, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, freeSeat, seatList: [{n, advisor}],
|
||||
bench, bonuses, buySeat, buySeatWhyText, selected,
|
||||
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, seat,
|
||||
canAfford, canHire, why, whyText, canSeat, seatWhy, seatWhyText, dismiss, removed}]}
|
||||
{seats, hired, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, bonuses, buySeat, buySeatWhyText,
|
||||
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, canAfford,
|
||||
canHire, why, whyText}]}
|
||||
```
|
||||
|
||||
Seats are numbered from 1. Only seated advisors give their bonus (`bonuses`); a hired
|
||||
advisor can be unseated and stays hired. Changes count from the next stake, sale or batch.
|
||||
`hired` is the seats filled, `benched` the advisors hired but not seated, `seatList` every
|
||||
seat with the name of who sits there (`""` empty), `bench` the names of the hired advisors
|
||||
not seated in the web's order (hire order), `freeSeat` the lowest empty seat (0 if
|
||||
none: where `hire` seats a new one). `selected` is the web's open panel (`view.adv`).
|
||||
`buySeat` is the buy-seat dialog (`null` at the cap), `buySeatWhyText` why "+ Seat" is
|
||||
disabled. An advisor's `why` (hiring): `hired` ("On your board"), `seats` ("All seats
|
||||
taken. Buy another seat first"), `credits` ("Need ₥X more") or `""`. For a hired advisor:
|
||||
`seat` (0: not seated), `dismiss` the dismiss-advisor dialog (`null` if not hired; nothing
|
||||
is refunded, hiring them again costs `price`); unseated ones also `canSeat` (a seat is
|
||||
free: `seat-advisor` with `seat=<freeSeat>`) and `seatWhy` `seats` ("All seats taken.
|
||||
Unseat someone or buy another seat") or `""`. A hired advisor no longer in the config comes
|
||||
last with `removed` (no bonus, no card on the web; it shows in its seat or the bench and can be
|
||||
dismissed).
|
||||
|
||||
The web's panel for a hired advisor (any client may do the same): for every other seat,
|
||||
`seat-advisor` to an empty one or `swap-advisors` with who sits there; when seated, also
|
||||
`swap-advisors` with each unseated advisor and `unseat-advisor`; and `dismiss-advisor`.
|
||||
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`)
|
||||
|
||||
@@ -269,27 +214,20 @@ progress is saved on this device). Its button is `new-game`, `newGame` its dialo
|
||||
|
||||
### newMoon (`s=new-moon`)
|
||||
|
||||
`{grade, grades, price, canAfford, after, need, types, resources, wellsMin, wellsMax, whyText}`: the
|
||||
fields besides `grades` are for the default `grade` (`state.moonGrade`): `after` the
|
||||
`{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. `grades` lists every moon grade, cheapest first:
|
||||
`[{grade, price, canAfford, wellsMin, wellsMax, supplyMin, supplyMax, default, whyText}]` (a
|
||||
well holds `supplyMin` up to just under `supplyMax`; `default` is the one `buy-moon` buys
|
||||
without a grade). Buy with `buy-moon` and `grade=<grade>`.
|
||||
`resources` what its wells can hold. Buy with `buy-moon`.
|
||||
|
||||
### reveal (in the `buy-moon` response)
|
||||
|
||||
```
|
||||
{moonId, name, kind, grade, description, rating, headline, sub,
|
||||
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}],
|
||||
next: {grade, price, canBuy, why, whyText}}
|
||||
{moonId, name, kind, description, rating, headline, sub,
|
||||
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}]}
|
||||
```
|
||||
|
||||
`rating`, `maxSupply` and `fullAt` are against moons of its `grade`. `headline`, `sub` and `traits` are the card's verdict text; `worth` its wells at market
|
||||
`headline`, `sub` and `traits` are the card's verdict text; `worth` its wells at market
|
||||
prices, `multiple` worth / its price; a well bar is `supply / maxSupply`, `full` from
|
||||
`fullAt`. `next` is the card's "Buy another" button (`buy-moon` with `grade=<next.grade>`, the
|
||||
grade just bought): its price, `canBuy`, and `why` `locked` (more moons come with the store: the web hides the
|
||||
button), `credits` ("Need ₥X more", the button disabled) or `""`.
|
||||
`fullAt`.
|
||||
|
||||
### buyModal (in the `buy-tool` response)
|
||||
|
||||
@@ -320,7 +258,7 @@ is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkl
|
||||
|
||||
## Texts
|
||||
|
||||
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
|
||||
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
|
||||
@@ -329,8 +267,11 @@ a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.no
|
||||
Not sent (the client words them from the numbers, like the web's templates):
|
||||
- moon list status: "N drills", "Idle", "Tapped out"; "A well here has more drills than
|
||||
supply" (`over`).
|
||||
- well note: "Over-drilled — next claims will come up short", "Fully committed", "X left · Y
|
||||
committed · runs dry after N claims" / "Z free after" (`demand`, `remain`, `dryAfter`).
|
||||
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
|
||||
current drills" (`well.idle[].short`, `here`, `free`).
|
||||
- market: change ("+1.25%"), pressure ("−12%"), boost ("×2"), advisor ("+10%").
|
||||
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
|
||||
|
||||
The web's formats:
|
||||
@@ -345,30 +286,14 @@ The web's formats:
|
||||
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: at most `amount`, from what its wells hold).
|
||||
- Shared wells: the selected moon's well fields (`demand`, `remain`, `dryAfter`, `over`,
|
||||
`full`, `fullyCommitted`, the notes, its drills' `gets`/`here`/`state`, `readyAmount`) and
|
||||
the deploy cards' `here`/`free`/`short`/`blocked` come from a projection the client may
|
||||
recompute: the moon's stakes, oldest `start` first (ties in `stakes` order), are claimed
|
||||
in turn, each from what the earlier ones left (`remain`, starting at `supply`). A one-well
|
||||
stake takes `min(amount, remain)` of its well; a multi-well stake (`reach` > 1) covers its
|
||||
own well (if not empty) then the others with something left, most first (ties in
|
||||
`moon.wells` order), up to `reach` wells, taking from each in turn as much as is left
|
||||
until it has `amount`. `demand` is the one-well amounts on the well plus what each
|
||||
multi-well stake would take from it out of `supply`; `dryAfter` the claim (among those
|
||||
taking from the well) that leaves it at 0. A deploy card's `here` is what a new stake
|
||||
of its `yield` and `reach` on the well gets after all of them (`free` the `remain` of
|
||||
the wells it covers); 0 is `blocked`, refused by the server. `readyAmount` is the ready
|
||||
one-well drills claimed in turn from `supply`. A claim takes `min(amount, supply)` in
|
||||
that order, and one that empties a well stops (idle, claim of 0) the other one-well
|
||||
drills on it.
|
||||
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 × min(qty, 1 + bulkLog × ln qty) / speed)` seconds
|
||||
- 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.
|
||||
@@ -393,12 +318,11 @@ Content-Type: application/json
|
||||
"state": {
|
||||
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
|
||||
"now": 1792391815265,
|
||||
"view": {"section": "moons", "moon": "54daa782-…", "well": "b9d643c4-…", "past": false, "page": 1, "pick": "", "drill": "", "adv": "", "aside": true},
|
||||
"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": 2500,
|
||||
"moonGrade": "B",
|
||||
"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"],
|
||||
@@ -408,7 +332,6 @@ Content-Type: application/json
|
||||
"drills": 1, "ready": 0, "over": false, "selected": true, "createdAt": 1792391738240,
|
||||
"finished": false, "finishedAt": null}],
|
||||
"past": [],
|
||||
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
|
||||
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
|
||||
"image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5,
|
||||
"reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}],
|
||||
@@ -431,7 +354,7 @@ Content-Type: application/json
|
||||
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
|
||||
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"},
|
||||
"catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
|
||||
"catalogVersion": "9adb4eabab929f5d"
|
||||
"catalogVersion": "7d983412e77a3dde"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -476,7 +399,6 @@ From a live server (long ids and arrays shortened).
|
||||
{"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,
|
||||
"nextBatch": 50, "nextBatchPrice": 6000, "canUpgradeBatch": true, "upgradeBatchWhyText": "",
|
||||
"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,
|
||||
@@ -487,19 +409,12 @@ From a live server (long ids and arrays shortened).
|
||||
`s=advisors`, `state.advisors`:
|
||||
|
||||
```json
|
||||
{"seats": 2, "hired": 1, "benched": 1, "maxSeats": 4, "nextSeat": 3, "nextSeatPrice": 40000, "canBuySeat": true,
|
||||
"seatFree": true, "freeSeat": 2, "seatList": [{"n": 1, "advisor": "Ada Voss"}, {"n": 2, "advisor": ""}], "bench": ["Mira Castellanos"],
|
||||
"bonuses": [{"kind": "speed", "mult": 1.1, "capped": false, "label": "Drill speed", "text": "+10%"}], "selected": "",
|
||||
"buySeat": {"title": "New seat", "text": "Add advisor seat 3 for ₥40,000?", "ok": "Buy seat", "danger": false},
|
||||
"buySeatWhyText": "",
|
||||
{"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}, "effectText": "+10% drill speed",
|
||||
"hired": true, "seat": 1, "canAfford": true, "canHire": false, "why": "hired", "whyText": "On your board",
|
||||
"canSeat": false, "seatWhy": "", "seatWhyText": "",
|
||||
"dismiss": {"title": "Dismiss advisor", "text": "Dismiss Ada Voss? The hiring fee isn't refunded: hiring them again costs ₥8,000.",
|
||||
"ok": "Dismiss", "danger": true}, "removed": false},
|
||||
{"name": "Mira Castellanos", "…": "…", "hired": true, "seat": 0, "canSeat": true, "seatWhy": ""}, "…"]}
|
||||
"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}}`
|
||||
@@ -507,10 +422,8 @@ From a live server (long ids and arrays shortened).
|
||||
`s=new-moon`, `state.newMoon`:
|
||||
|
||||
```json
|
||||
{"grade": "B", "grades": [{"grade": "D", "price": 150, "canAfford": true, "wellsMin": 1, "wellsMax": 2,
|
||||
"supplyMin": 16, "supplyMax": 32, "default": false, "whyText": ""}, "…"],
|
||||
"price": 2500, "canAfford": true, "after": 71079.5, "need": 0, "types": ["Caelusium", "Argentis", "…"],
|
||||
"resources": ["Azurium", "Nebulance", "Novafor", "Shadowstone", "Sollux"], "wellsMin": 2, "wellsMax": 4}
|
||||
{"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`:
|
||||
@@ -520,8 +433,7 @@ From a live server (long ids and arrays shortened).
|
||||
"rating": {"tier": "poor", "label": "Poor", "stars": 1, "pct": 12, "top": 88},
|
||||
"headline": "Slim pickings", "sub": "Not much down there. Better luck next time.", "worth": 717, "multiple": 1.79,
|
||||
"traits": [], "maxSupply": 90, "fullAt": 84,
|
||||
"wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"],
|
||||
"next": {"price": 550, "canBuy": true, "why": "", "whyText": ""}}
|
||||
"wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"]}
|
||||
```
|
||||
|
||||
`POST /api/act/buy-tool`, `buyModal`:
|
||||
@@ -61,11 +61,9 @@ type APITierUp struct {
|
||||
Tier int `json:"tier"`
|
||||
Price float64 `json:"price"`
|
||||
ClaimBoost float64 `json:"claimBoost"`
|
||||
Speed float64 `json:"speed"` // cycle speed multiplier at this tier (1: none)
|
||||
CycleSecs int `json:"cycleSecs"` // the tier's cycle before bonuses
|
||||
Reach int `json:"reach"` // wells per cycle from this tier on
|
||||
Parts []APIAmount `json:"parts"` // refined items it uses up
|
||||
Image string `json:"image"` // the tool's image at this tier
|
||||
Reach int `json:"reach"` // wells per cycle from this tier on
|
||||
Parts []APIAmount `json:"parts"` // refined items it uses up
|
||||
Image string `json:"image"` // the tool's image at this tier
|
||||
}
|
||||
|
||||
type APIEffect struct {
|
||||
@@ -150,9 +148,8 @@ func apiCatalog(s *State) *APICatalog {
|
||||
Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}}
|
||||
for _, u := range item.Upgrades {
|
||||
t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost,
|
||||
Speed: max(1, u.Speed), CycleSecs: item.ToolSecs(u.Tier),
|
||||
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier))})
|
||||
t.Tiers[len(t.Tiers)-1].Image = itemImages + item.ImageAt(u.Tier)
|
||||
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier)),
|
||||
Image: itemImages + item.ImageAt(u.Tier)})
|
||||
}
|
||||
cat.Tools = append(cat.Tools, t)
|
||||
}
|
||||
@@ -208,8 +205,8 @@ type APIConfirm struct {
|
||||
|
||||
// APIRules are the numbers a client needs to predict results (api.md, Prediction).
|
||||
type APIRules struct {
|
||||
WellSupplyMax float64 `json:"wellSupplyMax"` // the default grade's biggest well (each moon has its maxSupply)
|
||||
MaxSupply float64 `json:"maxSupply"` // the scale of the web's supply bars: the selected moon's grade
|
||||
WellSupplyMax float64 `json:"wellSupplyMax"` // config max: a full reveal bar is this × the well scale
|
||||
MaxSupply float64 `json:"maxSupply"` // the scale of the web's supply bars
|
||||
SlotsPerTool int `json:"slotsPerTool"`
|
||||
WellsMin int `json:"wellsMin"`
|
||||
WellsMax int `json:"wellsMax"`
|
||||
@@ -232,20 +229,20 @@ type APIPressure struct {
|
||||
}
|
||||
|
||||
type APIRefineryRules struct {
|
||||
BulkLog float64 `json:"bulkLog"`
|
||||
MaxBatch int `json:"maxBatch"`
|
||||
Speed float64 `json:"speed"` // advisors' refine bonus × the cycle event for "all"
|
||||
BulkExponent float64 `json:"bulkExponent"`
|
||||
MaxBatch int `json:"maxBatch"`
|
||||
Speed float64 `json:"speed"` // advisors' refine bonus × the cycle event for "all"
|
||||
}
|
||||
|
||||
func apiRules(p *Page) *APIRules {
|
||||
s, c := p.S, cfg()
|
||||
b := s.Bonus()
|
||||
lo, hi := s.wellCountRange()
|
||||
r := &APIRules{WellSupplyMax: s.MoonGrade().WellSupplyMax, MaxSupply: p.MaxSupply, SlotsPerTool: c.Attachments.SlotsPerTool,
|
||||
lo, hi := wellCountRange()
|
||||
r := &APIRules{WellSupplyMax: c.WellSupplyMax, MaxSupply: p.MaxSupply, SlotsPerTool: c.Attachments.SlotsPerTool,
|
||||
WellsMin: lo, WellsMax: hi,
|
||||
Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale,
|
||||
HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale},
|
||||
Refinery: APIRefineryRules{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")},
|
||||
MoonResale: c.MoonResale, EquipmentResale: c.EquipmentResale.Factor, BonusCaps: c.BonusCaps,
|
||||
Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield, Sell: b.Sell, Refine: b.Refine},
|
||||
@@ -642,23 +639,19 @@ func apiStore(p *Page) *APIStore {
|
||||
// ------------------------------------------------------------------ refinery
|
||||
|
||||
type APIRefinery struct {
|
||||
Slots []APIRefinerySlot `json:"slots"`
|
||||
SlotCount int `json:"slotCount"`
|
||||
MaxSlots int `json:"maxSlots"`
|
||||
NextSlot int `json:"nextSlot"`
|
||||
NextSlotPrice float64 `json:"nextSlotPrice"` // 0 at the cap
|
||||
CanBuySlot bool `json:"canBuySlot"`
|
||||
Free int `json:"free"`
|
||||
Ready int `json:"ready"`
|
||||
MaxBatch int `json:"maxBatch"` // most per batch now (batch upgrades bought)
|
||||
NextBatch int `json:"nextBatch"` // after upgrade-batch; 0 at the top
|
||||
NextBatchPrice float64 `json:"nextBatchPrice"` // 0 at the top
|
||||
CanUpgradeBatch bool `json:"canUpgradeBatch"`
|
||||
UpgradeBatchWhy string `json:"upgradeBatchWhyText"` // why the upgrade 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
|
||||
Slots []APIRefinerySlot `json:"slots"`
|
||||
SlotCount int `json:"slotCount"`
|
||||
MaxSlots int `json:"maxSlots"`
|
||||
NextSlot int `json:"nextSlot"`
|
||||
NextSlotPrice float64 `json:"nextSlotPrice"` // 0 at the cap
|
||||
CanBuySlot bool `json:"canBuySlot"`
|
||||
Free int `json:"free"`
|
||||
Ready int `json:"ready"`
|
||||
MaxBatch int `json:"maxBatch"`
|
||||
Recipes []APIRecipe `json:"recipes"`
|
||||
Stock []APIAmount `json:"stock"` // refined items owned
|
||||
BuySlot *APIConfirm `json:"buySlot"` // before buy-slot; null at the cap
|
||||
BuySlotWhy string `json:"buySlotWhyText"` // why "+ Slot" is disabled, "" if it isn't
|
||||
}
|
||||
|
||||
type APIRefinerySlot struct {
|
||||
@@ -690,11 +683,7 @@ func apiRefinery(p *Page) *APIRefinery {
|
||||
speed := s.Bonus().Refine * s.CycleBoosts.For("all")
|
||||
r := &APIRefinery{Slots: []APIRefinerySlot{}, SlotCount: rp.SlotCount, MaxSlots: rp.MaxSlots, NextSlot: rp.NextSlot,
|
||||
NextSlotPrice: rp.NextSlotPrice, CanBuySlot: rp.CanBuySlot, Free: rp.Free, Ready: rp.Ready, MaxBatch: rp.MaxBatch,
|
||||
NextBatch: rp.NextBatch, NextBatchPrice: rp.NextBatchPrice, CanUpgradeBatch: rp.CanUpgradeBatch,
|
||||
Recipes: []APIRecipe{}, Stock: []APIAmount{}}
|
||||
if rp.NextBatch > 0 {
|
||||
r.UpgradeBatchWhy = whyShort(rp.CanUpgradeBatch, s.Balance, rp.NextBatchPrice)
|
||||
}
|
||||
if rp.NextSlotPrice > 0 {
|
||||
r.BuySlot = &APIConfirm{Title: "New refinery slot", Text: rp.SlotConfirm(), OK: "Buy slot"}
|
||||
r.BuySlotWhy = whyShort(rp.CanBuySlot, s.Balance, rp.NextSlotPrice)
|
||||
@@ -739,27 +728,16 @@ func apiRefinery(p *Page) *APIRefinery {
|
||||
|
||||
type APIAdvisors struct {
|
||||
Seats int `json:"seats"`
|
||||
Hired int `json:"hired"` // seats filled
|
||||
Benched int `json:"benched"` // hired, not seated
|
||||
Hired int `json:"hired"`
|
||||
MaxSeats int `json:"maxSeats"`
|
||||
NextSeat int `json:"nextSeat"`
|
||||
NextSeatPrice float64 `json:"nextSeatPrice"` // 0 at the cap
|
||||
CanBuySeat bool `json:"canBuySeat"`
|
||||
SeatFree bool `json:"seatFree"`
|
||||
FreeSeat int `json:"freeSeat"` // the lowest empty seat, 0 if none
|
||||
SeatList []APISeat `json:"seatList"` // every seat, from 1
|
||||
Bench []string `json:"bench"` // hired, not seated, in the web's order (hire order)
|
||||
Bonuses []APIBonusRow `json:"bonuses"`
|
||||
Advisors []APIAdvisorUp `json:"advisors"`
|
||||
BuySeat *APIConfirm `json:"buySeat"` // before buy-seat; null at the cap
|
||||
BuySeatWhy string `json:"buySeatWhyText"` // why "+ Seat" is disabled, "" if it isn't
|
||||
Selected string `json:"selected"` // the advisor of the web's panel (view.adv), "" if none
|
||||
}
|
||||
|
||||
// APISeat is a seat and who sits in it ("" empty).
|
||||
type APISeat struct {
|
||||
N int `json:"n"`
|
||||
Advisor string `json:"advisor"`
|
||||
}
|
||||
|
||||
type APIAdvisorUp struct {
|
||||
@@ -770,48 +748,26 @@ type APIAdvisorUp struct {
|
||||
Price float64 `json:"price"`
|
||||
Effect APIEffect `json:"effect"`
|
||||
Hired bool `json:"hired"`
|
||||
Seat int `json:"seat"` // hired: their seat, 0 if not seated (no bonus)
|
||||
CanAfford bool `json:"canAfford"`
|
||||
CanHire bool `json:"canHire"` // not hired, a seat free, affordable
|
||||
EffectText string `json:"effectText"`
|
||||
Why string `json:"why"` // hired, seats (none free), credits; "" can hire
|
||||
WhyText string `json:"whyText"`
|
||||
// Hired and not seated: whether seat-advisor can seat them now (in freeSeat);
|
||||
// seatWhy "seats" (none free) or "".
|
||||
CanSeat bool `json:"canSeat"`
|
||||
SeatWhy string `json:"seatWhy"`
|
||||
SeatWhyText string `json:"seatWhyText"`
|
||||
Dismiss *APIConfirm `json:"dismiss"` // hired: the dismiss-advisor dialog; null otherwise
|
||||
Removed bool `json:"removed"` // hired but no longer in the config: no card on the web
|
||||
}
|
||||
|
||||
func apiAdvisors(p *Page) *APIAdvisors {
|
||||
ap := p.Advisors
|
||||
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, Benched: ap.Benched, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
|
||||
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(), FreeSeat: ap.FreeSeat,
|
||||
SeatList: []APISeat{}, Bench: []string{}, Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}, Selected: p.V.Adv}
|
||||
for _, slot := range ap.SeatList {
|
||||
seat := APISeat{N: slot.N}
|
||||
if slot.Advisor != nil {
|
||||
seat.Advisor = slot.Advisor.Name
|
||||
}
|
||||
a.SeatList = append(a.SeatList, seat)
|
||||
}
|
||||
for _, c := range ap.Bench {
|
||||
a.Bench = append(a.Bench, c.Name)
|
||||
}
|
||||
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
|
||||
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(),
|
||||
Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}}
|
||||
if ap.NextSeatPrice > 0 {
|
||||
a.BuySeat = &APIConfirm{Title: "New seat", Text: ap.SeatConfirm(), OK: "Buy seat"}
|
||||
a.BuySeatWhy = whyShort(ap.CanBuySeat, p.S.Balance, ap.NextSeatPrice)
|
||||
}
|
||||
up := func(c AdvisorCard) APIAdvisorUp {
|
||||
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Description: c.Description, Price: c.Price,
|
||||
Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)),
|
||||
Hired: c.Hired, Seat: c.Seat, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(),
|
||||
EffectText: c.Advisor.Effect()}
|
||||
if c.Image != "" {
|
||||
up.Image = advisorImages + c.Image
|
||||
}
|
||||
for _, c := range ap.Cards {
|
||||
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Image: advisorImages + c.Image,
|
||||
Description: c.Description, Price: c.Price, Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)),
|
||||
Hired: c.Hired, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(), EffectText: c.Advisor.Effect()}
|
||||
switch {
|
||||
case c.Hired:
|
||||
up.Why, up.WhyText = "hired", "On your board"
|
||||
@@ -820,42 +776,11 @@ func apiAdvisors(p *Page) *APIAdvisors {
|
||||
case !c.CanAfford:
|
||||
up.Why, up.WhyText = "credits", ap.HireWhy(c, p.S.Balance)
|
||||
}
|
||||
if c.Hired {
|
||||
up.Dismiss = &APIConfirm{Title: "Dismiss advisor", Text: dismissConfirm(c), OK: "Dismiss", Danger: true}
|
||||
if c.Seat == 0 {
|
||||
up.CanSeat = ap.SeatFree()
|
||||
if !up.CanSeat {
|
||||
up.SeatWhy, up.SeatWhyText = "seats", ap.SeatWhy()
|
||||
}
|
||||
}
|
||||
}
|
||||
return up
|
||||
}
|
||||
for _, c := range ap.Cards {
|
||||
a.Advisors = append(a.Advisors, up(c))
|
||||
}
|
||||
// Hired advisors no longer in the config (no bonus) come last.
|
||||
for _, c := range append(seated(ap.SeatList), ap.Bench...) {
|
||||
if cfg().advisor(c.Name) == nil {
|
||||
r := up(c)
|
||||
r.Removed = true
|
||||
a.Advisors = append(a.Advisors, r)
|
||||
}
|
||||
a.Advisors = append(a.Advisors, up)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// seated lists the advisors in the seats.
|
||||
func seated(seats []SeatSlot) []AdvisorCard {
|
||||
var out []AdvisorCard
|
||||
for _, slot := range seats {
|
||||
if slot.Advisor != nil {
|
||||
out = append(out, *slot.Advisor)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ leaderboard, help, new moon
|
||||
|
||||
type APILeaderboard struct {
|
||||
@@ -917,49 +842,21 @@ func apiHelp(s *State) *APIHelp {
|
||||
}
|
||||
|
||||
type APINewMoon struct {
|
||||
Grade string `json:"grade"` // the default grade (buy-moon without a grade); the fields below are its
|
||||
Grades []APIMoonGrade `json:"grades"` // every grade, cheapest first: buy-moon with grade=<grade>
|
||||
Price float64 `json:"price"`
|
||||
CanAfford bool `json:"canAfford"`
|
||||
After float64 `json:"after"` // balance after buying
|
||||
Need float64 `json:"need"` // credits missing
|
||||
Types []string `json:"types"`
|
||||
Resources []string `json:"resources"`
|
||||
WellsMin int `json:"wellsMin"`
|
||||
WellsMax int `json:"wellsMax"`
|
||||
WhyText string `json:"whyText"` // why "Claim and reveal" is disabled, "" if it isn't
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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}
|
||||
if !g.CanAfford {
|
||||
a.WhyText = "Not enough credits"
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
Price float64 `json:"price"`
|
||||
CanAfford bool `json:"canAfford"`
|
||||
After float64 `json:"after"` // balance after buying
|
||||
Need float64 `json:"need"` // credits missing
|
||||
Types []string `json:"types"`
|
||||
Resources []string `json:"resources"`
|
||||
WellsMin int `json:"wellsMin"`
|
||||
WellsMax int `json:"wellsMax"`
|
||||
WhyText string `json:"whyText"` // why "Claim and reveal" is disabled, "" if it isn't
|
||||
}
|
||||
|
||||
func apiNewMoon(t *NewMoonTeaser) *APINewMoon {
|
||||
lo, hi := cfg().grade(t.Grade).wellCountRange()
|
||||
a := &APINewMoon{Grade: t.Grade, Grades: apiMoonGrades(t.Grades), Price: t.Price, CanAfford: t.CanAfford, After: t.After,
|
||||
Need: t.Need, Types: []string{}, Resources: []string{}, WellsMin: lo, WellsMax: hi}
|
||||
lo, hi := wellCountRange()
|
||||
a := &APINewMoon{Price: t.Price, CanAfford: t.CanAfford, After: t.After, Need: t.Need, Types: []string{},
|
||||
Resources: []string{}, WellsMin: lo, WellsMax: hi}
|
||||
if !t.CanAfford {
|
||||
a.WhyText = "Not enough credits"
|
||||
}
|
||||
@@ -973,9 +870,8 @@ type APIReveal struct {
|
||||
MoonID string `json:"moonId"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Grade string `json:"grade"`
|
||||
Description string `json:"description"`
|
||||
Rating APIRating `json:"rating"` // against moons of its grade
|
||||
Rating APIRating `json:"rating"`
|
||||
Headline string `json:"headline"`
|
||||
Sub string `json:"sub"`
|
||||
Worth float64 `json:"worth"` // its wells at market prices now
|
||||
@@ -984,16 +880,6 @@ type APIReveal struct {
|
||||
MaxSupply float64 `json:"maxSupply"` // a full bar
|
||||
FullAt float64 `json:"fullAt"` // wells from here count as full
|
||||
Wells []APIRevealWell `json:"wells"`
|
||||
Next APINextMoon `json:"next"` // "Buy another": buy-moon again
|
||||
}
|
||||
|
||||
// APINextMoon is the reveal card's "Buy another" button.
|
||||
type APINextMoon struct {
|
||||
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 {
|
||||
@@ -1007,11 +893,10 @@ func apiReveal(r *MoonReveal) *APIReveal {
|
||||
if r == 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),
|
||||
Headline: r.Headline, Sub: r.Sub, Worth: r.Worth, Multiple: r.Multiple, Traits: []string{}, MaxSupply: r.MaxSupply,
|
||||
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}}
|
||||
FullAt: r.FullAt, Wells: []APIRevealWell{}}
|
||||
a.Traits = append(a.Traits, r.Traits...)
|
||||
for _, w := range r.Wells {
|
||||
a.Wells = append(a.Wells, APIRevealWell{ID: w.ID, Resource: w.Resource, Supply: w.Supply, Full: w.Supply >= r.FullAt})
|
||||
@@ -5,24 +5,27 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPI(t *testing.T) {
|
||||
// Every tool has art at every tier (scripts/tool-art.mjs), else its URLs fall back.
|
||||
for _, item := range game.StoreItems {
|
||||
for tier := 0; tier <= len(item.Upgrades); tier++ {
|
||||
name := item.ImageAt(tier)
|
||||
if _, err := fs.Stat(embedded, "web/static/assets/items/"+name); err != nil || (tier > 0 && name == item.Image) {
|
||||
t.Errorf("%s: no image for tier %d (run node scripts/tool-art.mjs)", item.Name, tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
unlocked := newPlayerState()
|
||||
unlocked.Onboarding = []string{"all"}
|
||||
asides := map[bool]bool{}
|
||||
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
|
||||
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} {
|
||||
for _, v := range testViews(s) {
|
||||
s.pagePastInMemory(v.Page)
|
||||
p := s.page(v.resolve(s))
|
||||
testRanking(p)
|
||||
p.Admin = len(s.Tools) > 0
|
||||
a := apiState(p)
|
||||
if a.Admin != p.Admin {
|
||||
t.Errorf("view %+v: admin %v", v, a.Admin)
|
||||
}
|
||||
if _, err := json.Marshal(a); err != nil {
|
||||
t.Fatalf("marshal %+v: %v", v, err)
|
||||
}
|
||||
@@ -43,7 +46,7 @@ func TestAPI(t *testing.T) {
|
||||
}
|
||||
if a.Market != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -59,8 +62,7 @@ func TestAPI(t *testing.T) {
|
||||
}
|
||||
if p.Moon != nil {
|
||||
// The reveal card and the buy modal, as an action response maps them.
|
||||
if r := apiReveal(p.moonReveal()); r == nil || len(r.Wells) != len(p.Moon.Wells) || r.Headline == "" ||
|
||||
r.Next.Price != s.MoonPrice() || r.Next.CanBuy != (r.Next.Why == "") || (r.Next.Why == "") != (r.Next.WhyText == "") {
|
||||
if r := apiReveal(p.moonReveal()); r == nil || len(r.Wells) != len(p.Moon.Wells) || r.Headline == "" {
|
||||
t.Errorf("view %+v: reveal %+v", v, r)
|
||||
}
|
||||
}
|
||||
@@ -88,13 +90,6 @@ func TestAPI(t *testing.T) {
|
||||
if _, err := json.Marshal(all); err != nil {
|
||||
t.Fatalf("marshal %+v with every section: %v", v, err)
|
||||
}
|
||||
// The aside: the moon view, the new-moon teaser, an open drill or advisor panel.
|
||||
aside := p.Moon != nil || p.V.Section == "new-moon" || p.Drill != nil ||
|
||||
p.Advisors != nil && p.Advisors.Panel != nil
|
||||
if a.View.Aside != aside || aside != (p.Scene.Select != nil) {
|
||||
t.Errorf("view %+v: aside %v, want %v", p.V, a.View.Aside, aside)
|
||||
}
|
||||
asides[a.View.Aside] = true
|
||||
if a.View.Section != p.V.Section || a.View.Moon != p.V.Moon || a.View.Well != p.V.Well {
|
||||
t.Errorf("view %+v: state has %+v", p.V, a.View)
|
||||
}
|
||||
@@ -109,18 +104,11 @@ func TestAPI(t *testing.T) {
|
||||
if (a.Moon != nil) != (p.Moon != nil) || (a.Well != nil) != (p.Well != nil) {
|
||||
t.Errorf("view %+v: moon or well missing", v)
|
||||
}
|
||||
if pp := a.PastPage; len(a.Past) > pp.PerPage || pp.Page < 1 || pp.Page > pp.Pages || pp.Total != s.PastTotal ||
|
||||
a.View.Page != pp.Page || pp.Page < pp.Pages && len(a.Past) != pp.PerPage {
|
||||
t.Errorf("view %+v: past page %+v with %d moons", v, pp, len(a.Past))
|
||||
}
|
||||
if (a.Market != nil) != s.Unlocked("market") {
|
||||
t.Errorf("view %+v: market is %v, unlocked %v", v, a.Market != nil, s.Unlocked("market"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !asides[true] || !asides[false] {
|
||||
t.Errorf("views with and without the aside: %v", asides)
|
||||
}
|
||||
a := apiState(newPlayerState().page(View{}.resolve(newPlayerState())))
|
||||
if a.Unlocked["market"] || !a.Unlocked["new-moon"] || a.View.Section != "new-moon" {
|
||||
t.Errorf("new player: unlocked %v, section %q", a.Unlocked, a.View.Section)
|
||||
@@ -162,11 +150,8 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
|
||||
if len(w.Drills) > 0 && w.Label == "" {
|
||||
fail("well label", w)
|
||||
}
|
||||
if (w.Over || w.Full) != (w.StatusText != "") || (w.Demand > 0) != (w.NoteText != "") {
|
||||
fail("well note", w)
|
||||
}
|
||||
for _, d := range w.Drills {
|
||||
if d.Fate == "" || (d.Reach > 1) != (d.ReachText != "") {
|
||||
if d.Fate == "" {
|
||||
fail("drill fate", d)
|
||||
}
|
||||
}
|
||||
@@ -175,13 +160,6 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
|
||||
fail("moon sale", sale)
|
||||
}
|
||||
}
|
||||
if a.Market != nil {
|
||||
for _, q := range a.Market.Quotes {
|
||||
if q.ChangeText == "" || q.Pressed != (q.PressureText != "") || q.Boosted != (q.BoostText != "") || q.Advised != (q.AdvisorText != "") {
|
||||
fail("quote texts", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, b := range a.Bonuses {
|
||||
if b.Label == "" || b.Text == "" {
|
||||
fail("bonus", b)
|
||||
@@ -239,10 +217,6 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
|
||||
if (up.Why != "") != (up.WhyText != "") || up.Why == "" && !up.CanHire {
|
||||
fail("advisor texts", up)
|
||||
}
|
||||
if up.Hired != (up.Dismiss != nil) || up.Dismiss != nil && up.Dismiss.Text == "" ||
|
||||
(up.SeatWhy != "") != (up.SeatWhyText != "") || up.CanSeat && (up.Seat != 0 || ad.FreeSeat == 0) {
|
||||
fail("advisor seat texts", up)
|
||||
}
|
||||
}
|
||||
}
|
||||
if h := a.Help; h != nil && h.NewGame.Text != newGameConfirm {
|
||||
@@ -280,15 +254,9 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
|
||||
t.Errorf("view %+v: refinery %+v", v, a.Refinery)
|
||||
}
|
||||
case "advisors":
|
||||
if a.Advisors == nil || len(a.Advisors.Advisors) < len(cfg().Advisors.Items) || len(a.Advisors.SeatList) != s.AdvisorSeats ||
|
||||
a.Advisors.Hired+a.Advisors.Benched != len(s.Hires) || a.Advisors.Selected != v.Adv || len(a.Advisors.Bench) != a.Advisors.Benched {
|
||||
if a.Advisors == nil || len(a.Advisors.Advisors) != len(cfg().Advisors.Items) {
|
||||
t.Errorf("view %+v: advisors %+v", v, a.Advisors)
|
||||
}
|
||||
for _, h := range s.Hires {
|
||||
if h.Seat > 0 && a.Advisors.SeatList[h.Seat-1].Advisor != h.Name {
|
||||
t.Errorf("view %+v: seat %d has %q, not %s", v, h.Seat, a.Advisors.SeatList[h.Seat-1].Advisor, h.Name)
|
||||
}
|
||||
}
|
||||
case "leaderboard":
|
||||
if a.Leaderboard == nil || a.Leaderboard.Me == nil || a.Leaderboard.Me.Rank != 12 {
|
||||
t.Errorf("view %+v: leaderboard %+v", v, a.Leaderboard)
|
||||
@@ -307,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)
|
||||
}
|
||||
}
|
||||
@@ -282,7 +282,7 @@ func (s *State) drillPanel(row ToolRow) *DrillPanel {
|
||||
}
|
||||
sg := SpareGear{Attachment: a, Count: n, CanEquip: true}
|
||||
names := append([]string{a.Name}, s.Fitted[row.ID]...)
|
||||
sg.Secs, sg.Yield = drillStats(row.Item.ToolSecs(row.Tier), row.Item.ToolYield(row.Tier),
|
||||
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 {
|
||||
@@ -20,8 +20,9 @@ func runSim(args []string) error {
|
||||
}
|
||||
}
|
||||
a := analyze(&c, nil, 0)
|
||||
printGradeSim(&c)
|
||||
fmt.Printf("\navg price %.2f default grade: avg well %.0f units (%.2f wells)\n", a.AvgPrice, a.AvgSupply, a.AvgWells)
|
||||
fmt.Printf("avg price %.2f avg well %.0f units (%.2f wells) moon: price %s, ore %.0f units worth %s (%.2f×)\n",
|
||||
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.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 {
|
||||
@@ -40,8 +41,30 @@ func runSim(args []string) error {
|
||||
for _, s := range a.Refining.Slots {
|
||||
fmt.Printf(" slot %d %s payback %s\n", s.Slot, formatCredits(s.Price), s.Payback)
|
||||
}
|
||||
if os.Getenv("OLDSIM") != "" {
|
||||
printOldSim(a.Sim)
|
||||
s := 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")
|
||||
for _, w := range a.Warnings {
|
||||
@@ -49,15 +72,3 @@ func runSim(args []string) error {
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -114,12 +114,12 @@ func (cfg *GameConfig) validateBonuses() error {
|
||||
|
||||
// ------------------------------------------------------------------ in a transaction
|
||||
|
||||
// loadBonus reads the player's seated advisors and, with a tool id, the tool's attachments, and
|
||||
// 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.
|
||||
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
select advisor from advisor_hire where user_id = $1 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 {
|
||||
return noBonus, err
|
||||
}
|
||||
@@ -143,28 +143,20 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, er
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
|
||||
// loadBonusState loads the player's attachments, advisors (hired, and the seated ones) and
|
||||
// advisor seats into the state.
|
||||
// loadBonusState loads the player's attachments, advisors and advisor seats into the state.
|
||||
func loadBonusState(ctx context.Context, db querier, s *State) error {
|
||||
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := db.Query(ctx, `
|
||||
select advisor, coalesce(seat, 0) from advisor_hire where user_id = $1
|
||||
order by seat nulls last, hired_at, advisor`, s.UserID)
|
||||
select advisor from advisor_hire where user_id = $1 order by hired_at, advisor`, s.UserID)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
s.Advisors = nil
|
||||
for _, h := range s.Hires {
|
||||
if h.Seat > 0 {
|
||||
s.Advisors = append(s.Advisors, h.Name)
|
||||
}
|
||||
}
|
||||
rows, err = db.Query(ctx, `
|
||||
select coalesce(inventory_item_id::text, ''), item from attachment
|
||||
where user_id = $1 order by item`, s.UserID)
|
||||
@@ -194,14 +186,13 @@ func (s *State) toolBonus(toolID string) Bonus {
|
||||
|
||||
// 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) (int, float64) {
|
||||
return drillStats(item.ToolSecs(t.Tier), item.ToolYield(t.Tier), s.toolBonus(t.ID),
|
||||
return drillStats(item.CompletionTimeInSecs, item.ToolYield(t.Tier), s.toolBonus(t.ID),
|
||||
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
|
||||
}
|
||||
|
||||
// sellPrice is what one unit pays the player now, before selling pressure: the shared
|
||||
// quote × their advisors.
|
||||
// sellPrice is what one unit sells for now: market rate × live events × advisors.
|
||||
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.
|
||||
@@ -1,7 +0,0 @@
|
||||
build/
|
||||
tools/
|
||||
.internal/
|
||||
*.der
|
||||
.DS_Store
|
||||
dist/
|
||||
__pycache__/
|
||||
@@ -1,194 +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`). Same layout and HUD look as the web build
|
||||
(header, moon list left, the section in the middle, aside with the moon viewport and the
|
||||
selected well), 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`, header and moon list sigs likewise) or it won't update. 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 app.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 web (`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).
|
||||
- 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`. `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, terrain.js settings port).
|
||||
|
||||
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
|
||||
(C port of terrain.js; `scripts/moon-check.mjs` compares, in `make check`).
|
||||
- `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,100 +0,0 @@
|
||||
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool,
|
||||
# python3 and luajit for shot, play, drive, dev and check.
|
||||
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).
|
||||
# `make serve-web` serves it on :8080 with /api and /static proxied to UPSTREAM.
|
||||
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) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
||||
|
||||
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
|
||||
play: build
|
||||
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --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, Equipment 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, tier chips, attachment tags): a 1 px outlined box with
|
||||
-- a short label, optionally filled. Returns the box node and its width.
|
||||
--
|
||||
-- chip.new(parent, "T2/5", 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,96 +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)
|
||||
-- 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)
|
||||
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 -- (setting the data scrolls to the top)
|
||||
self.items = items
|
||||
self.data:set_data(items)
|
||||
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the
|
||||
-- scroll event).
|
||||
local s = self.scroll
|
||||
s.target_position.y = saved
|
||||
s:_set_scroll_position(s.position.x, saved)
|
||||
s.target_position.y = s.position.y
|
||||
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,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
|
||||
@@ -1,79 +0,0 @@
|
||||
fonts {
|
||||
name: "sans_semi"
|
||||
font: "/assets/fonts/sans_semi.font"
|
||||
}
|
||||
textures {
|
||||
name: "ui"
|
||||
texture: "/assets/ui.atlas"
|
||||
}
|
||||
nodes {
|
||||
position {
|
||||
x: 0.0000
|
||||
y: 0.0000
|
||||
}
|
||||
size {
|
||||
x: 160.0000
|
||||
y: 44.0000
|
||||
}
|
||||
color {
|
||||
x: 0.3725
|
||||
y: 0.8314
|
||||
z: 1.0000
|
||||
}
|
||||
type: TYPE_BOX
|
||||
texture: "ui/white"
|
||||
id: "root"
|
||||
pivot: PIVOT_NW
|
||||
alpha: 0.1
|
||||
}
|
||||
nodes {
|
||||
position {
|
||||
x: 0.0000
|
||||
y: 0.0000
|
||||
}
|
||||
size {
|
||||
x: 160.0000
|
||||
y: 44.0000
|
||||
}
|
||||
color {
|
||||
x: 0.3725
|
||||
y: 0.8314
|
||||
z: 1.0000
|
||||
}
|
||||
type: TYPE_BOX
|
||||
texture: "ui/outline"
|
||||
id: "edge"
|
||||
pivot: PIVOT_NW
|
||||
parent: "root"
|
||||
slice9 {
|
||||
x: 2.0000
|
||||
y: 2.0000
|
||||
z: 2.0000
|
||||
w: 2.0000
|
||||
}
|
||||
}
|
||||
nodes {
|
||||
position {
|
||||
x: 80.0000
|
||||
y: -22.0000
|
||||
}
|
||||
size {
|
||||
x: 150.0000
|
||||
y: 24.0000
|
||||
}
|
||||
color {
|
||||
x: 0.8745
|
||||
y: 0.9647
|
||||
z: 1.0000
|
||||
}
|
||||
type: TYPE_TEXT
|
||||
text: "Button"
|
||||
font: "sans_semi"
|
||||
id: "text"
|
||||
pivot: PIVOT_CENTER
|
||||
parent: "root"
|
||||
outline_alpha: 0.0
|
||||
shadow_alpha: 0.0
|
||||
}
|
||||
material: "/builtins/materials/gui.material"
|
||||
adjust_reference: ADJUST_REFERENCE_PARENT
|
||||