Compare commits
127
Commits
7e18f99796
..
main
No files matched your search
@@ -8,20 +8,21 @@ and stake them on wells. When a stake finishes (drill cycles are seconds:
|
|||||||
`completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
|
`completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
|
||||||
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined
|
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
|
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
|
for the higher upgrades). Each drill has two upgrade tracks of its own (Equipment: one raises
|
||||||
its panel in Equipment) give it a modifier that makes it faster or bigger; advisors
|
its claim per cycle, the other shortens its cycle, a level at a time, per tool); advisors
|
||||||
(Advisors section, one per seat) boost drill speed, yield, sell prices or the refinery for
|
(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,
|
the whole account. There is also a leaderboard (Leaderboard section: top miners by credits,
|
||||||
banned players left out).
|
banned players left out).
|
||||||
|
|
||||||
## How the parts fit
|
## How the parts fit
|
||||||
- `server/`: the Go server (game rules, Postgres, admin pages) and the web client, which it
|
- `server/`: the Go server: game rules, Postgres, the JSON API and the admin pages
|
||||||
renders as HTML (htmx, three.js moon). The server is the only authority on game state.
|
(`/utils`, server-rendered with htmx, behind `ADMIN_PASSWORD`). The server is the only
|
||||||
Details: `server/AGENTS.md`.
|
authority on game state. Details: `server/AGENTS.md`.
|
||||||
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's
|
- `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
|
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`.
|
what it sends. It is the only game client, and its moon generator
|
||||||
Details: `client/AGENTS.md`.
|
(`client/modules/moon/terrain.lua`, native `mmterrain`) is the only one. Details:
|
||||||
|
`client/AGENTS.md`.
|
||||||
- Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
|
- Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
|
||||||
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
|
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
|
||||||
|
|
||||||
@@ -35,6 +36,11 @@ banned players left out).
|
|||||||
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
|
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>`
|
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/`.
|
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
|
||||||
|
- A worktree's `.env` names its own database (`DB_DATABASE=mm_<task>`), so `migrate`,
|
||||||
|
tests and servers run there reach only `mm_<task>`. Never point anything at the main
|
||||||
|
database (`moonminers`): it holds the author's game.
|
||||||
|
- Never use `git stash` in a worktree: the stash list is shared by every worktree, so a
|
||||||
|
pop can take another agent's work. Commit on your branch, or save a patch file.
|
||||||
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
||||||
commit it.
|
commit it.
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
|
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
|
||||||
|
|
||||||
- `server/`: Go server, JSON API (`server/api.md`) and the web client (htmx + three.js).
|
- `server/`: Go server: the JSON API (`server/api.md`) and the admin pages (`/utils`).
|
||||||
- `client/`: Defold client, for desktop and the browser (WebAssembly).
|
- `client/`: Defold client, for desktop and the browser (WebAssembly).
|
||||||
|
|
||||||
## Server and web client
|
## Server
|
||||||
|
|
||||||
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
||||||
`server/README.md`).
|
`server/README.md`).
|
||||||
@@ -14,25 +14,32 @@ Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
|||||||
podman-compose up -d db # or: docker compose up -d db
|
podman-compose up -d db # or: docker compose up -d db
|
||||||
cd server
|
cd server
|
||||||
make migrate
|
make migrate
|
||||||
make dev # http://localhost:3000
|
make dev # http://localhost:3000: the game (once built, below); /utils: the admin
|
||||||
```
|
```
|
||||||
|
|
||||||
After pulling changes, run `make migrate` again before starting the server: it applies new
|
After pulling changes or editing `server/config/game.json`, run `make migrate` again before
|
||||||
migrations and syncs the tools with `config/game.json` (the server alone doesn't).
|
starting the server: it applies new migrations and syncs the tools and prices with the
|
||||||
|
config (the server alone doesn't).
|
||||||
|
|
||||||
## Defold client
|
## Defold client
|
||||||
|
|
||||||
Needs Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor
|
Needs the Defold editor (https://defold.com/download/) or Java 21+. The Makefile downloads
|
||||||
is optional: https://defold.com/download/
|
Defold's command-line builder (`bob.jar`) and runs it with the Java the editor ships, found
|
||||||
|
in the usual places on Linux, macOS and Windows (`make DEFOLD=<folder holding its packages>`
|
||||||
With the server running:
|
for another place, `JAVA=<path>` for your own Java); without an editor, `java` from PATH.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd client
|
||||||
make run # desktop build, plays against http://localhost:3000
|
make run # desktop build, plays against http://localhost:3000
|
||||||
make web && make serve-web # browser build: open http://localhost:8080
|
make web # browser build into dist/web/MoonMiners/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The server serves the browser build at http://localhost:3000 (from
|
||||||
|
`client/dist/web/MoonMiners`, or `MM_WEB_DIR`). It looks for the build when it starts:
|
||||||
|
after the first `make web`, restart the server; later builds only need a page reload.
|
||||||
|
Without a build, `/` goes to the admin. The desktop and browser games keep separate saved
|
||||||
|
players.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
|
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
|
||||||
+17
-11
@@ -1,9 +1,9 @@
|
|||||||
# AGENTS.md — Moon Miners client (Defold)
|
# AGENTS.md — Moon Miners client (Defold)
|
||||||
|
|
||||||
A native client for Moon Miners (rules and state live in the Go server: `../server`,
|
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
|
`../server/AGENTS.md`; the repo: `../AGENTS.md`), and the only game client. Layout and HUD
|
||||||
(header, moon list left, the section in the middle, aside with the moon viewport and the
|
look come from the old web client (header, moon list left, the section in the middle, aside with the moon viewport and the
|
||||||
selected well), made game-native. Target 16:10 at 1280x800 (Steam Deck); bigger windows
|
selected well; on Moons the aside only shows, every action is on the well cards), made game-native. Target 16:10 at 1280x800 (Steam Deck); bigger windows
|
||||||
show the UI at native pixel size with more room, smaller ones rearrange. Mouse now,
|
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
|
gamepad later. Details live in each file's header comment; this file has the rules and a
|
||||||
map.
|
map.
|
||||||
@@ -42,12 +42,16 @@ map.
|
|||||||
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
|
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
|
||||||
layout is top-left based (x right, y down).
|
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;
|
- 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
|
`ASIDE_KEYS` and the header's sig likewise; the moon list's rows: `moonlist.rows_sig`;
|
||||||
|
the moon's facts in the aside: `aside.facts_sig`; the Moons parts: their models in
|
||||||
|
moons.lua) or it won't update. The shown state is frozen (`blocks.freeze`: `blocks.sig`
|
||||||
|
writes each part once per state, by content): never change `g.state` in place; a
|
||||||
|
prediction changes its copy before it is shown. What must survive
|
||||||
a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across
|
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
|
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.note`/`push`/`put`); code that builds outside a build runs through
|
||||||
`blocks.bound(fn)`.
|
`blocks.bound(fn)`.
|
||||||
- Theme: every color is a token (`theme.color`, dark and light from app.css): no hex or
|
- Theme: every color is a token (`theme.color`, dark and light, from the old web client's CSS): no hex or
|
||||||
vector colors in code (templates via `theme.template`, config colors via `theme.item`,
|
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`.
|
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
|
- Text: styles from `theme.text`; `₥`, `★` and other symbols through the `sym` font
|
||||||
@@ -56,7 +60,7 @@ map.
|
|||||||
- Node budget: `max_nodes: 4096` in `game.gui`; text past the font batch limit silently
|
- 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.
|
doesn't render (make check catches the warning); stencils and images split batches.
|
||||||
Outline textures use slice9 2.
|
Outline textures use slice9 2.
|
||||||
- Motion: easing curves match the web (`modules/anim.lua`). GUI nodes don't pass alpha to
|
- Motion: easing curves match the old web client (`modules/anim.lua`). GUI nodes don't pass alpha to
|
||||||
children: fade content in from under a veil, not by fading a subtree. Every new
|
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
|
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.
|
`modules/fx.lua`; numbers that change ease (`H.tick_num`); the wallet counts through fx.
|
||||||
@@ -140,7 +144,8 @@ an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
|
|||||||
- `core/keys.lua`: keyboard (focus, Enter/Space, scrolling keys, fields, F11, Ctrl +/-/0).
|
- `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.
|
- `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
|
- Regions: `banner.lua`, `header.lua`, `moonlist.lua`, `aside.lua`, `settings.lua` (gear
|
||||||
menu), `savecode.lua`, `grant.lua` (admin grant dialog).
|
menu), `savecode.lua`, `grant.lua` (admin grant dialog), `admin.lua` (admin area from the gear
|
||||||
|
menu, instead of the page; screens: `moonlab.lua`, the moon settings editor).
|
||||||
- Sections: `moons.lua`, `market.lua` (+ `ticker.lua`, `chart.lua`, `sale.lua`,
|
- Sections: `moons.lua`, `market.lua` (+ `ticker.lua`, `chart.lua`, `sale.lua`,
|
||||||
`leaderboard.lua`), `refinery.lua`, `equipment.lua`, `store.lua`, `advisors.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`
|
`help.lua`, `moon_sale.lua`, `reveal.lua` (new-moon teaser and reveal window), `cards.lua`
|
||||||
@@ -149,7 +154,7 @@ an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
|
|||||||
|
|
||||||
Components (`components/`, API in each header; build new UI from these): `button`,
|
Components (`components/`, API in each header; build new UI from these): `button`,
|
||||||
`dialog`, `panel`, `card` (+ `card.grid`), `tabs`, `list` (long lists, Druid Data List),
|
`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.
|
`scroll`, `bar`, `chip`, `stat`, `row`, `slider`. `widgets/moon_row.lua`: the moon list row.
|
||||||
|
|
||||||
Modules (`modules/`):
|
Modules (`modules/`):
|
||||||
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
|
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
|
||||||
@@ -163,14 +168,15 @@ Modules (`modules/`):
|
|||||||
`settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
|
`settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
|
||||||
- `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
|
- `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
|
||||||
`hot.lua` (hot reload).
|
`hot.lua` (hot reload).
|
||||||
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, terrain.js settings port).
|
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, the moon settings and
|
||||||
|
their `DEFAULTS`, the look the server's `moondesc.go` mirrors).
|
||||||
|
|
||||||
Elsewhere:
|
Elsewhere:
|
||||||
- `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
|
- `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
|
||||||
- `moon/`: moon collection, `moon.script`, materials (port of scene.js).
|
- `moon/`: moon collection, `moon.script`, materials (port of scene.js).
|
||||||
`render/mm.render_script`: HDR moon pass with bloom.
|
`render/mm.render_script`: HDR moon pass with bloom.
|
||||||
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator
|
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator, the
|
||||||
(C port of terrain.js; `scripts/moon-check.mjs` compares, in `make check`).
|
game's only one (it began as a C port of the web client's terrain.js, now deleted).
|
||||||
- `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
|
- `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
|
||||||
`ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
|
`ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
|
||||||
- `web/index.html`: the web build's page.
|
- `web/index.html`: the web build's page.
|
||||||
|
|||||||
+28
-13
@@ -1,5 +1,17 @@
|
|||||||
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool,
|
# Moon Miners Defold client. Builds with bob.jar, run by the Java the Defold editor ships
|
||||||
# python3 and luajit for shot, play, drive, dev and check.
|
# (its packages/jdk-*), so the editor is all a build needs. The editor is looked for where it
|
||||||
|
# usually is on Linux (~/Defold, /opt/Defold), macOS (Defold.app in /Applications or
|
||||||
|
# ~/Applications) and Windows (~/Defold, C:/Defold, Git Bash or MSYS make); DEFOLD=<the folder
|
||||||
|
# holding its packages> for another place, JAVA=<path> for a Java of your own. With no editor
|
||||||
|
# found it is java (21+) from PATH. xvfb-run, Xvfb, ImageMagick, xdotool, python3 and luajit
|
||||||
|
# for shot, play, drive, dev and check (Linux).
|
||||||
|
DEFOLD_DIRS := $(DEFOLD) $(HOME)/Defold /opt/Defold \
|
||||||
|
/Applications/Defold.app/Contents/Resources $(HOME)/Applications/Defold.app/Contents/Resources \
|
||||||
|
$(USERPROFILE)/Defold C:/Defold
|
||||||
|
# (make's own wildcard and sort, no shell tools; the last after sorting is the newest)
|
||||||
|
DEFOLD_JAVA := $(lastword $(sort $(foreach d,$(DEFOLD_DIRS),$(wildcard \
|
||||||
|
$(d)/packages/jdk*/bin/java $(d)/packages/jdk*/bin/java.exe $(d)/packages/jdk*/Contents/Home/bin/java))))
|
||||||
|
JAVA ?= $(or $(DEFOLD_JAVA),java)
|
||||||
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
|
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
|
||||||
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
|
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
|
||||||
BOB := tools/bob.jar
|
BOB := tools/bob.jar
|
||||||
@@ -18,25 +30,26 @@ $(BOB):
|
|||||||
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
|
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
|
||||||
|
|
||||||
resolve: $(BOB)
|
resolve: $(BOB)
|
||||||
java -jar $(BOB) --platform $(PLATFORM) resolve
|
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
|
||||||
|
|
||||||
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
|
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
|
||||||
.internal/lib/dependencies.json: game.project | $(BOB)
|
.internal/lib/dependencies.json: game.project | $(BOB)
|
||||||
java -jar $(BOB) --platform $(PLATFORM) resolve
|
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
|
||||||
touch $@
|
touch $@
|
||||||
|
|
||||||
build: $(BOB) .internal/lib/dependencies.json
|
build: $(BOB) .internal/lib/dependencies.json
|
||||||
java -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
|
$(JAVA) -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
|
||||||
--archive --bundle-output dist/$(PLATFORM) build bundle
|
--archive --bundle-output dist/$(PLATFORM) build bundle
|
||||||
|
|
||||||
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release
|
# 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).
|
# 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.
|
# The game server serves it at / (server.go, MM_WEB_DIR); `make serve-web` serves it on :8080
|
||||||
|
# with /api and /static proxied to UPSTREAM (a test server).
|
||||||
WEB_VARIANT ?= release
|
WEB_VARIANT ?= release
|
||||||
UPSTREAM ?= http://localhost:3000
|
UPSTREAM ?= http://localhost:3000
|
||||||
WEB_PORT ?= 8080
|
WEB_PORT ?= 8080
|
||||||
web: $(BOB) .internal/lib/dependencies.json
|
web: $(BOB) .internal/lib/dependencies.json
|
||||||
java -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
|
$(JAVA) -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
|
||||||
--archive --bundle-output dist/web build bundle
|
--archive --bundle-output dist/web build bundle
|
||||||
|
|
||||||
serve-web:
|
serve-web:
|
||||||
@@ -50,7 +63,7 @@ dev: build
|
|||||||
|
|
||||||
reload: $(BOB)
|
reload: $(BOB)
|
||||||
@mkdir -p dist/dev
|
@mkdir -p dist/dev
|
||||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|
@$(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; }
|
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
|
||||||
@scripts/dev.py reload
|
@scripts/dev.py reload
|
||||||
|
|
||||||
@@ -63,7 +76,7 @@ SCENARIO ?= smoke
|
|||||||
DRIVE ?= --fixture
|
DRIVE ?= --fixture
|
||||||
drive: $(BOB) .internal/lib/dependencies.json
|
drive: $(BOB) .internal/lib/dependencies.json
|
||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
@$(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; }
|
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
||||||
scripts/drive.py $(DRIVE) $(SCENARIO)
|
scripts/drive.py $(DRIVE) $(SCENARIO)
|
||||||
|
|
||||||
@@ -71,7 +84,7 @@ drive: $(BOB) .internal/lib/dependencies.json
|
|||||||
# dist/drive/lint-<size>/. Needs MM_API (a test server).
|
# dist/drive/lint-<size>/. Needs MM_API (a test server).
|
||||||
lint: $(BOB) .internal/lib/dependencies.json
|
lint: $(BOB) .internal/lib/dependencies.json
|
||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
@$(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; }
|
|| { 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
|
@for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done
|
||||||
|
|
||||||
@@ -79,7 +92,7 @@ lint: $(BOB) .internal/lib/dependencies.json
|
|||||||
# scenario run (sections) against MM_API (required: a test server) if it answers.
|
# scenario run (sections) against MM_API (required: a test server) if it answers.
|
||||||
check: $(BOB) .internal/lib/dependencies.json
|
check: $(BOB) .internal/lib/dependencies.json
|
||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|
@$(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; }
|
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
|
||||||
scripts/check.py
|
scripts/check.py
|
||||||
|
|
||||||
@@ -90,11 +103,13 @@ run: build
|
|||||||
|
|
||||||
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
|
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
|
||||||
shot: build
|
shot: build
|
||||||
scripts/shot.sh $(BIN) $(SHOT) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
scripts/shot.sh $(BIN) $(SHOT) $(if $(MM_API),--config=mm.api_base=$(MM_API)) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
||||||
|
|
||||||
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
|
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
|
||||||
|
# Needs MM_API (a test server): it makes a new player there.
|
||||||
play: build
|
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)
|
@test -n "$(MM_API)" || { echo "make play: set MM_API to a test server (no default)"; exit 1; }
|
||||||
|
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.api_base=$(MM_API) --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf build dist .internal/cache
|
rm -rf build dist .internal/cache
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
-- on top instead (the web's card). card.grid lays cards out in columns (as many as
|
-- 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
|
-- card_col_min allows, like the web's grid; centered and capped by the caller), equal
|
||||||
-- heights per row, and
|
-- heights per row, and
|
||||||
-- registers them for the panel entrance (g.frames, staggered). Store, Equipment and
|
-- registers them for the panel entrance (g.frames, staggered). Store and
|
||||||
-- Advisors use it; the Refinery's recipes too (opts media = 0: no image).
|
-- 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)
|
-- y = card.grid(g, parent, x, y, inner, items, function(c, item)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
-- Chip / badge (the web's .badge, tier chips, attachment tags): a 1 px outlined box with
|
-- Chip / badge (the web's .badge, level chips, advisor tags): a 1 px outlined box with
|
||||||
-- a short label, optionally filled. Returns the box node and its width.
|
-- 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, "Y2 C1", x, y, { color = "line_hi", text = "text_2", font = "mono" })
|
||||||
-- chip.new(parent, 3, x, y, { color = "ready", font = "mono", px = 12, min_w = 22 })
|
-- chip.new(parent, 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 })
|
-- chip.new(parent, "Turbo core", x, y, { color = "violet", text = "violet_hi", upper = true })
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
-- local l = list.new(g, parent, x, y, w, h, {
|
-- local l = list.new(g, parent, x, y, w, h, {
|
||||||
-- key = "moons", row_h = 64, -- the row's slot (gap included)
|
-- key = "moons", row_h = 64, -- the row's slot (gap included)
|
||||||
-- create = function(item, index) ... return root[, druid component] end,
|
-- create = function(item, index) ... return root[, druid component] end,
|
||||||
-- focus_id = function(item) return "moon:" .. item.id end }) -- (optional) the test
|
-- focus_id = function(item) return "moon:" .. item.id end, -- (optional) the test
|
||||||
-- -- id its row registers: Tab walks
|
-- -- id its row registers: Tab walks
|
||||||
-- -- every row, scrolling (modules/focus)
|
-- -- every row, scrolling (modules/focus)
|
||||||
|
-- on_remove = function(node, item) ... end }) -- (optional) a row scrolled out, before
|
||||||
|
-- -- its node is deleted (not when the
|
||||||
|
-- -- list itself goes in a rebuild)
|
||||||
-- l:set_data(items) -- (again with a filtered list: the rows are made again)
|
-- 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
|
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
|
||||||
@@ -43,6 +46,13 @@ function M.new(g, parent, x, y, w, h, opts)
|
|||||||
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
|
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
|
||||||
return l.create(item, index)
|
return l.create(item, index)
|
||||||
end)
|
end)
|
||||||
|
if opts.on_remove then
|
||||||
|
l.data.on_element_remove:subscribe(function(_, _, node, _, item)
|
||||||
|
if not g.tearing_down then
|
||||||
|
opts.on_remove(node, item)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
l.scroll.on_scroll:subscribe(function(_, pos)
|
l.scroll.on_scroll:subscribe(function(_, pos)
|
||||||
if g.tearing_down then
|
if g.tearing_down then
|
||||||
return -- (the old list emptying itself during a rebuild: keep the offset)
|
return -- (the old list emptying itself during a rebuild: keep the offset)
|
||||||
@@ -55,15 +65,16 @@ function M.new(g, parent, x, y, w, h, opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function M:set_data(items)
|
function M:set_data(items)
|
||||||
local saved = self.g.scroll_pos[self.key] or 0 -- (setting the data scrolls to the top)
|
local saved = self.g.scroll_pos[self.key] or 0
|
||||||
self.items = items
|
self.items = items
|
||||||
self.data:set_data(items)
|
-- At the saved offset first (Druid clamps it to the rows), so the data list makes the
|
||||||
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the
|
-- rows there, not the top ones only to drop them again.
|
||||||
-- scroll event).
|
|
||||||
local s = self.scroll
|
local s = self.scroll
|
||||||
|
s:set_size(self.grid:get_size_for(#items))
|
||||||
s.target_position.y = saved
|
s.target_position.y = saved
|
||||||
s:_set_scroll_position(s.position.x, saved)
|
s:_set_scroll_position(s.position.x, saved)
|
||||||
s.target_position.y = s.position.y
|
s.target_position.y = s.position.y
|
||||||
|
self.data:set_data(items)
|
||||||
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
|
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
|
||||||
for _, a in ipairs(self.g.scroll_areas) do
|
for _, a in ipairs(self.g.scroll_areas) do
|
||||||
if a.list == self then
|
if a.list == self then
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
-- Slider (the web's input type=range): a thin track, its filled part and a knob. Press
|
||||||
|
-- anywhere on the track's row to jump there, drag to move; the value follows the pointer
|
||||||
|
-- exactly (screen position mapped into the track, not summed deltas).
|
||||||
|
--
|
||||||
|
-- local s = slider.new(g, parent, x, y, w, h, { min, max, step, value, id, zone,
|
||||||
|
-- on_change = fn(v), on_release = fn(v) })
|
||||||
|
-- s:set(v) -- move it without on_change (another control changed the value)
|
||||||
|
--
|
||||||
|
-- Built on Druid's slider (input priority high, so a scroll area under it doesn't take
|
||||||
|
-- the drag); its input is replaced by the pointer mapping above. zone: a node the press
|
||||||
|
-- must be inside (a scroll area's view: hidden rows can't be grabbed). on_change gets
|
||||||
|
-- the value snapped to step; on_release when the drag ends. Test id: opts.id (kind
|
||||||
|
-- "slider", no keyboard focus: the number field beside it is the keyboard's way in).
|
||||||
|
|
||||||
|
local ui = require("modules.ui")
|
||||||
|
local test = require("modules.test")
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
M.__index = M
|
||||||
|
|
||||||
|
local TOUCH = hash("touch")
|
||||||
|
local KNOB_W, KNOB_H, TRACK_H = 10, 16, 4
|
||||||
|
|
||||||
|
local function vec(x, y)
|
||||||
|
return vmath.vector3(x, y, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function snap(v, o)
|
||||||
|
local n = math.floor((v - o.min) / o.step + 0.5)
|
||||||
|
v = o.min + n * o.step
|
||||||
|
-- (decimal steps: drop float noise like 0.30000000000000004)
|
||||||
|
v = tonumber(string.format("%.6f", v))
|
||||||
|
return math.max(o.min, math.min(o.max, v))
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.new(g, parent, x, y, w, h, opts)
|
||||||
|
local self = setmetatable({ opts = opts, w = w }, M)
|
||||||
|
local hit = ui.rect(parent, x, y, w, h, "none")
|
||||||
|
ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), w, TRACK_H, "line_hi")
|
||||||
|
self.fill = ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), 0, TRACK_H, "cyan", 0.7)
|
||||||
|
local knob = ui.rect(hit, 0, 0, KNOB_W, KNOB_H, "cyan")
|
||||||
|
gui.set_pivot(knob, gui.PIVOT_CENTER)
|
||||||
|
gui.set_position(knob, vec(0, -h / 2))
|
||||||
|
self.knob = knob
|
||||||
|
local s = g.druid:new_slider(knob, vec(w, -h / 2), function(_, f)
|
||||||
|
local v = snap(opts.min + f * (opts.max - opts.min), opts)
|
||||||
|
self:_fill(f)
|
||||||
|
if v ~= self.value then
|
||||||
|
self.value = v
|
||||||
|
if opts.on_change then
|
||||||
|
opts.on_change(v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
self.s = s
|
||||||
|
local zone = opts.zone
|
||||||
|
s.on_input = function(sl, action_id, action)
|
||||||
|
if action_id ~= TOUCH or not action.screen_x then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not sl.is_drag then
|
||||||
|
if not (action.pressed and gui.pick_node(hit, action.x, action.y)
|
||||||
|
and (not zone or gui.pick_node(zone, action.x, action.y))) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
sl.is_drag = true
|
||||||
|
end
|
||||||
|
local p = gui.screen_to_local(knob, vec(action.screen_x, action.screen_y))
|
||||||
|
local f = math.max(0, math.min(1, p.x / w))
|
||||||
|
if f ~= sl.value then
|
||||||
|
sl:set(f)
|
||||||
|
end
|
||||||
|
if action.released then
|
||||||
|
sl.is_drag = false
|
||||||
|
if opts.on_release then
|
||||||
|
opts.on_release(self.value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
self:set(opts.value)
|
||||||
|
test.register(opts.id, hit, "slider", opts.label, zone, true)
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function M:_fill(f)
|
||||||
|
gui.set_size(self.fill, vec(math.floor(self.w * f + 0.5), TRACK_H))
|
||||||
|
end
|
||||||
|
|
||||||
|
function M:set(v)
|
||||||
|
local o = self.opts
|
||||||
|
v = math.max(o.min, math.min(o.max, tonumber(v) or o.min))
|
||||||
|
self.value = v
|
||||||
|
local f = o.max > o.min and (v - o.min) / (o.max - o.min) or 0
|
||||||
|
self.s:set(f, true)
|
||||||
|
self:_fill(f)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
+25
-5
@@ -160,6 +160,7 @@ end
|
|||||||
|
|
||||||
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
|
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
|
||||||
local running = {}
|
local running = {}
|
||||||
|
local shaking = {} -- { node, t, rest = { p } } of the shakes running (M.shake)
|
||||||
|
|
||||||
local function now()
|
local function now()
|
||||||
return socket.gettime()
|
return socket.gettime()
|
||||||
@@ -208,6 +209,7 @@ end
|
|||||||
-- Drop every frame-driven tween (a screen going away).
|
-- Drop every frame-driven tween (a screen going away).
|
||||||
function M.clear()
|
function M.clear()
|
||||||
running = {}
|
running = {}
|
||||||
|
shaking = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
---------------------------------------------------------------- fading content
|
---------------------------------------------------------------- fading content
|
||||||
@@ -343,21 +345,39 @@ end
|
|||||||
|
|
||||||
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
|
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
|
||||||
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
|
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
|
||||||
|
-- A shake that starts while another runs on the node replaces it and keeps its resting
|
||||||
|
-- place (read mid-shake, the position is off by up to amp and would stay so).
|
||||||
local SHAKE = { { 0, 0 }, { 0.2, -1 }, { 0.4, 5 / 6 }, { 0.6, -0.5 }, { 0.8, 1 / 3 }, { 1, 0 } }
|
local SHAKE = { { 0, 0 }, { 0.2, -1 }, { 0.4, 5 / 6 }, { 0.6, -0.5 }, { 0.8, 1 / 3 }, { 1, 0 } }
|
||||||
function M.shake(node, amp, dur, delay)
|
function M.shake(node, amp, dur, delay)
|
||||||
if M.reduced then
|
if M.reduced then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
amp, dur = amp or 6, dur or 0.4
|
amp, dur = amp or 6, dur or 0.4
|
||||||
local p
|
local rest = {}
|
||||||
return M.tween(node, dur, function(k, n)
|
local keep = {}
|
||||||
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay)
|
for _, s in ipairs(shaking) do
|
||||||
|
if not s.t.dead then
|
||||||
|
local ok, same = pcall(function() return s.node == node end)
|
||||||
|
if ok and same then
|
||||||
|
M.cancel(s.t)
|
||||||
|
rest = s.rest
|
||||||
|
else
|
||||||
|
keep[#keep + 1] = s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
shaking = keep
|
||||||
|
local t = M.tween(node, dur, function(k, n)
|
||||||
|
rest.p = rest.p or gui.get_position(n) -- where it is when the shake starts (after a delay)
|
||||||
|
local p = rest.p
|
||||||
gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
|
gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
|
||||||
end, delay or 0, function()
|
end, delay or 0, function()
|
||||||
if p then
|
if rest.p then
|
||||||
pcall(gui.set_position, node, p)
|
pcall(gui.set_position, node, rest.p)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
shaking[#shaking + 1] = { node = node, t = t, rest = rest }
|
||||||
|
return t
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 1D value noise (smooth), for shakes.
|
-- 1D value noise (smooth), for shakes.
|
||||||
|
|||||||
+13
-1
@@ -161,7 +161,9 @@ local function request(method, path, params, cb)
|
|||||||
err = "network error"
|
err = "network error"
|
||||||
elseif status < 200 or status >= 300 then
|
elseif status < 200 or status >= 300 then
|
||||||
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
|
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
|
||||||
elseif data == nil then
|
elseif data == nil and status ~= 204 and method == "POST" then
|
||||||
|
err = "bad JSON"
|
||||||
|
elseif data == nil and method == "GET" and response.response ~= "null" then
|
||||||
err = "bad JSON"
|
err = "bad JSON"
|
||||||
end
|
end
|
||||||
done(err, data, status, recv)
|
done(err, data, status, recv)
|
||||||
@@ -185,6 +187,16 @@ function M.get_save_code(cb)
|
|||||||
request("GET", "/api/save-code", nil, cb)
|
request("GET", "/api/save-code", nil, cb)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The moon look tuned in the moon lab: data is the saved object, or nil when none is saved
|
||||||
|
-- (GET answers null). save posts the whole settings object (admins; 204 on success).
|
||||||
|
function M.get_moon_settings(cb)
|
||||||
|
request("GET", "/api/moon-settings", nil, cb)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.save_moon_settings(settings, cb)
|
||||||
|
request("POST", "/api/moon-settings", settings, cb)
|
||||||
|
end
|
||||||
|
|
||||||
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
|
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
|
||||||
function M.act(name, params, cb)
|
function M.act(name, params, cb)
|
||||||
if reject_name ~= "" and name == reject_name then
|
if reject_name ~= "" and name == reject_name then
|
||||||
|
|||||||
@@ -36,9 +36,50 @@ end
|
|||||||
|
|
||||||
---------------------------------------------------------------- signatures
|
---------------------------------------------------------------- signatures
|
||||||
|
|
||||||
local function ser(v, buf)
|
-- Frozen tables (M.freeze: a state's parts, which nothing changes once shown) are written
|
||||||
|
-- as a token of their content, worked out once per table: two tables with the same
|
||||||
|
-- content get the same token, so a block whose inputs the server's answer left as the
|
||||||
|
-- prediction had them is kept, and a list as long as the moons is serialized once per
|
||||||
|
-- state, not once per block and build. (A frozen table must not change.)
|
||||||
|
local frozen = setmetatable({}, { __mode = "k" }) -- table -> true (not worked out yet) or its token
|
||||||
|
local tokens, token_bytes, token_n = {}, 0, 0 -- content -> token; the content strings' size
|
||||||
|
local TOKEN_BYTES = 4 * 1024 * 1024 -- (then forgotten: equal content gets a new token once)
|
||||||
|
|
||||||
|
local ser
|
||||||
|
|
||||||
|
local function token_of(v)
|
||||||
|
local tok = frozen[v]
|
||||||
|
if tok ~= true then
|
||||||
|
return tok
|
||||||
|
end
|
||||||
|
local buf = {}
|
||||||
|
frozen[v] = false -- (while it's written: a table inside itself is written out)
|
||||||
|
ser(v, buf, true)
|
||||||
|
local s = table.concat(buf)
|
||||||
|
tok = tokens[s]
|
||||||
|
if not tok then
|
||||||
|
if token_bytes > TOKEN_BYTES then
|
||||||
|
tokens, token_bytes = {}, 0
|
||||||
|
end
|
||||||
|
token_n = token_n + 1
|
||||||
|
tok = "#" .. token_n
|
||||||
|
tokens[s], token_bytes = tok, token_bytes + #s
|
||||||
|
end
|
||||||
|
frozen[v] = tok
|
||||||
|
return tok
|
||||||
|
end
|
||||||
|
|
||||||
|
local function key_lt(a, b)
|
||||||
|
return tostring(a) < tostring(b)
|
||||||
|
end
|
||||||
|
|
||||||
|
ser = function(v, buf, own)
|
||||||
local t = type(v)
|
local t = type(v)
|
||||||
if t == "table" then
|
if t == "table" then
|
||||||
|
if not own and frozen[v] then
|
||||||
|
buf[#buf + 1] = token_of(v)
|
||||||
|
return
|
||||||
|
end
|
||||||
buf[#buf + 1] = "{"
|
buf[#buf + 1] = "{"
|
||||||
local n = #v
|
local n = #v
|
||||||
for i = 1, n do
|
for i = 1, n do
|
||||||
@@ -55,7 +96,7 @@ local function ser(v, buf)
|
|||||||
end
|
end
|
||||||
if keys then
|
if keys then
|
||||||
if mixed then
|
if mixed then
|
||||||
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
|
table.sort(keys, key_lt)
|
||||||
else
|
else
|
||||||
table.sort(keys)
|
table.sort(keys)
|
||||||
end
|
end
|
||||||
@@ -68,7 +109,9 @@ local function ser(v, buf)
|
|||||||
end
|
end
|
||||||
buf[#buf + 1] = "}"
|
buf[#buf + 1] = "}"
|
||||||
elseif t == "string" then
|
elseif t == "string" then
|
||||||
buf[#buf + 1] = #v .. ":" .. v
|
buf[#buf + 1] = #v
|
||||||
|
buf[#buf + 1] = ":"
|
||||||
|
buf[#buf + 1] = v
|
||||||
elseif t == "function" then
|
elseif t == "function" then
|
||||||
buf[#buf + 1] = "f"
|
buf[#buf + 1] = "f"
|
||||||
else
|
else
|
||||||
@@ -76,7 +119,8 @@ local function ser(v, buf)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A string that is the same exactly when the values are (tables by content, keys sorted).
|
-- A string that is the same exactly when the values are (tables by content, keys sorted;
|
||||||
|
-- frozen ones by their token, the same for the same content).
|
||||||
function M.sig(...)
|
function M.sig(...)
|
||||||
local buf = {}
|
local buf = {}
|
||||||
for i = 1, select("#", ...) do
|
for i = 1, select("#", ...) do
|
||||||
@@ -86,6 +130,23 @@ function M.sig(...)
|
|||||||
return table.concat(buf)
|
return table.concat(buf)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Freeze the tables in t (a state: its lists and records, one level down; t itself too)
|
||||||
|
-- for M.sig: nothing may change them afterwards (a prediction changes its copy before).
|
||||||
|
function M.freeze(t)
|
||||||
|
if type(t) ~= "table" then
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
if frozen[t] == nil then
|
||||||
|
frozen[t] = true
|
||||||
|
end
|
||||||
|
for _, v in pairs(t) do
|
||||||
|
if type(v) == "table" and frozen[v] == nil then
|
||||||
|
frozen[v] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
---------------------------------------------------------------- registrations
|
---------------------------------------------------------------- registrations
|
||||||
|
|
||||||
-- Note a registration made while building (fn(...) makes it again in a later build that
|
-- Note a registration made while building (fn(...) makes it again in a later build that
|
||||||
@@ -216,6 +277,37 @@ local function rezone(b)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- After a kept block is taken: its Druid components (and those of the blocks inside it)
|
||||||
|
-- get new uids, in their order, as if made now. Druid hands input to the newest first
|
||||||
|
-- (by uid: druid_instance's sort_input_comparator), so a kept button inside a box rebuilt
|
||||||
|
-- around it (a slot in a well card, which selects the well when clicked) still gets its
|
||||||
|
-- clicks first, as a rebuilt one would.
|
||||||
|
local new_uid = require("druid.component").create_uid
|
||||||
|
local function renew(c)
|
||||||
|
local cc = c._component
|
||||||
|
if cc and cc._uid then
|
||||||
|
cc._uid = new_uid()
|
||||||
|
cc._is_input_priority_changed = true -- (the instance sorts its input stack again)
|
||||||
|
end
|
||||||
|
local kids = c.get_childrens and c:get_childrens() or {}
|
||||||
|
for i = 1, #kids do -- (already the whole subtree, in its order)
|
||||||
|
local k = kids[i]._component
|
||||||
|
if k and k._uid then
|
||||||
|
k._uid = new_uid()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function reorder(b)
|
||||||
|
for _, c in ipairs(b.comps) do
|
||||||
|
pcall(renew, c)
|
||||||
|
end
|
||||||
|
for _, e in ipairs(b.journal) do
|
||||||
|
if e.block and not e.block.dead then
|
||||||
|
reorder(e.block)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- True while a kept block's registrations are made again (test.register looks for its
|
-- True while a kept block's registrations are made again (test.register looks for its
|
||||||
-- clip zone again then: the one it was given may have gone with the old parent).
|
-- clip zone again then: the one it was given may have gone with the old parent).
|
||||||
function M.replaying()
|
function M.replaying()
|
||||||
@@ -318,6 +410,7 @@ function M.open(g, key, sig, parent)
|
|||||||
reg.used[key] = true
|
reg.used[key] = true
|
||||||
gui.set_parent(old.root, parent, false)
|
gui.set_parent(old.root, parent, false)
|
||||||
rezone(old)
|
rezone(old)
|
||||||
|
reorder(old)
|
||||||
adopt(reg, old)
|
adopt(reg, old)
|
||||||
return old, true
|
return old, true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ local M = {}
|
|||||||
|
|
||||||
local EPS = 0.01
|
local EPS = 0.01
|
||||||
|
|
||||||
|
-- A copy of a caller's opts (the label leaves change their copy: one opts table can serve
|
||||||
|
-- several labels).
|
||||||
|
local function copy(opts)
|
||||||
|
local s = {}
|
||||||
|
for k, v in pairs(opts or {}) do
|
||||||
|
s[k] = v
|
||||||
|
end
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
local function differs(a, b)
|
local function differs(a, b)
|
||||||
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
|
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
|
||||||
end
|
end
|
||||||
@@ -111,7 +121,7 @@ function M.label(parent, str, style, h, opts)
|
|||||||
if ui.needs_rich(str) and not (type(style) == "table" and style.markup) then -- (symbols: ui.rich runs, a container whose y is the middle)
|
if ui.needs_rich(str) and not (type(style) == "table" and style.markup) then -- (symbols: ui.rich runs, a container whose y is the middle)
|
||||||
local n, w = ui.rich(parent, str, style, 0, 0)
|
local n, w = ui.rich(parent, str, style, 0, 0)
|
||||||
gui.set_pivot(n, gui.PIVOT_W)
|
gui.set_pivot(n, gui.PIVOT_W)
|
||||||
local s = opts or {}
|
local s = copy(opts)
|
||||||
s.fit, s.height = nil, h
|
s.fit, s.height = nil, h
|
||||||
return { node = n, size = false, style = s, measure = function() return w, h end }
|
return { node = n, size = false, style = s, measure = function() return w, h end }
|
||||||
end
|
end
|
||||||
@@ -124,7 +134,7 @@ function M.label(parent, str, style, h, opts)
|
|||||||
w = w - ui.text_width(n)
|
w = w - ui.text_width(n)
|
||||||
gui.set_text(n, t)
|
gui.set_text(n, t)
|
||||||
end
|
end
|
||||||
local s = opts or {}
|
local s = copy(opts)
|
||||||
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||||
s.fit, s.shrink_text, s.height = nil, nil, h
|
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||||
return { node = n, size = false, fit = fit, shrink = shrink, style = s, measure = function() return w, h end }
|
return { node = n, size = false, fit = fit, shrink = shrink, style = s, measure = function() return w, h end }
|
||||||
@@ -139,7 +149,7 @@ function M.para(parent, str, style, lh, opts)
|
|||||||
local one = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), "Ag")
|
local one = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), "Ag")
|
||||||
local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
|
local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
|
||||||
gui.set_leading(n, lh / line)
|
gui.set_leading(n, lh / line)
|
||||||
local s = opts or {}
|
local s = copy(opts)
|
||||||
local lines = s.lines
|
local lines = s.lines
|
||||||
s.lines = nil
|
s.lines = nil
|
||||||
local measure = M.text_measure(n, lh)
|
local measure = M.text_measure(n, lh)
|
||||||
@@ -223,7 +233,7 @@ function M.made(style, make, kids) return M.box(style, kids, { make = make }) en
|
|||||||
-- A leaf for a text node that already exists (a template's part): pivot W, its middle at
|
-- A leaf for a text node that already exists (a template's part): pivot W, its middle at
|
||||||
-- the box's middle, h tall, as wide as its text (set the text first).
|
-- the box's middle, h tall, as wide as its text (set the text first).
|
||||||
function M.node_label(node, h, opts)
|
function M.node_label(node, h, opts)
|
||||||
local s = opts or {}
|
local s = copy(opts)
|
||||||
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||||
s.fit, s.shrink_text, s.height = nil, nil, h
|
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||||
return { node = node, size = false, fit = fit, shrink = shrink, style = s, measure = function() return ui.text_width(node), h end }
|
return { node = node, size = false, fit = fit, shrink = shrink, style = s, measure = function() return ui.text_width(node), h end }
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ local M = {}
|
|||||||
|
|
||||||
local entries = {} -- { key, node, kind, label, zone, scope }
|
local entries = {} -- { key, node, kind, label, zone, scope }
|
||||||
local scope = "page"
|
local scope = "page"
|
||||||
local seen = {} -- key -> count in this build (duplicate ids get "#n")
|
local seen = {} -- base -> entries with it (duplicate ids get "#n")
|
||||||
|
|
||||||
local function alive(node)
|
local function alive(node)
|
||||||
return pcall(gui.get_id, node)
|
return pcall(gui.get_id, node)
|
||||||
@@ -67,8 +67,30 @@ function M.add(id, node, kind, label, zone)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local base = scope .. ":" .. (id or ("label:" .. tostring(label)))
|
local base = scope .. ":" .. (id or ("label:" .. tostring(label)))
|
||||||
|
local key = base
|
||||||
|
if (seen[base] or 0) > 0 then
|
||||||
|
-- A row made again (a long list scrolled back, a search, a kept block replayed)
|
||||||
|
-- leaves its deleted node's entry behind: drop those, so the live row keeps the
|
||||||
|
-- plain key that list_step and find look for.
|
||||||
|
local keep, taken = {}, {}
|
||||||
|
for _, e in ipairs(entries) do
|
||||||
|
if e.base ~= base then
|
||||||
|
keep[#keep + 1] = e
|
||||||
|
elseif alive(e.node) then
|
||||||
|
keep[#keep + 1] = e
|
||||||
|
taken[e.key] = true
|
||||||
|
else
|
||||||
|
seen[base] = seen[base] - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
entries = keep
|
||||||
|
local n = 1
|
||||||
|
while taken[key] do
|
||||||
|
n = n + 1
|
||||||
|
key = base .. "#" .. n
|
||||||
|
end
|
||||||
|
end
|
||||||
seen[base] = (seen[base] or 0) + 1
|
seen[base] = (seen[base] or 0) + 1
|
||||||
local key = seen[base] > 1 and (base .. "#" .. seen[base]) or base
|
|
||||||
entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
|
entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -225,12 +247,16 @@ end
|
|||||||
|
|
||||||
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
|
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
|
||||||
-- the item before / after the focused row (or the first / last when coming from outside),
|
-- the item before / after the focused row (or the first / last when coming from outside),
|
||||||
-- scrolled into view; the rebuild makes its row, which then has the focus. Returns true
|
-- scrolled into view; the rebuild makes its row, which then has the focus. Within the rows
|
||||||
-- when it moved the focus.
|
-- in view Tab goes in order (a row's own buttons too); from a button inside a row it steps
|
||||||
|
-- from that row (the one under it). Returns true when it moved the focus.
|
||||||
local function list_step(g, f, cur, e, dir, scope_)
|
local function list_step(g, f, cur, e, dir, scope_)
|
||||||
local a = list_of(g, cur)
|
local a = list_of(g, cur)
|
||||||
local items, i
|
local items, i
|
||||||
if a then
|
if a then
|
||||||
|
if list_of(g, e) == a then
|
||||||
|
return false -- (the next element is in view in the same list)
|
||||||
|
end
|
||||||
items = a.list.items
|
items = a.list.items
|
||||||
local id = cur.base:sub(#scope_ + 2)
|
local id = cur.base:sub(#scope_ + 2)
|
||||||
for n, item in ipairs(items) do
|
for n, item in ipairs(items) do
|
||||||
@@ -238,6 +264,11 @@ local function list_step(g, f, cur, e, dir, scope_)
|
|||||||
i = n + dir
|
i = n + dir
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if not i and a.list.row_h then
|
||||||
|
local r, v = M.rect(cur.node), M.rect(a.view)
|
||||||
|
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
|
||||||
|
i = math.floor((r.y + r.h / 2 - v.y + pos) / a.list.row_h) + 1 + dir
|
||||||
|
end
|
||||||
if not i or i < 1 or i > #items then
|
if not i or i < 1 or i > #items then
|
||||||
return false -- (leaving the list: the next element on the page)
|
return false -- (leaving the list: the next element on the page)
|
||||||
end
|
end
|
||||||
|
|||||||
+23
-5
@@ -40,17 +40,17 @@ local GOLD_HI = C.ready_hi
|
|||||||
local LOSS = C.loss
|
local LOSS = C.loss
|
||||||
local FLASH = C.flash
|
local FLASH = C.flash
|
||||||
|
|
||||||
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true, ["buy-attachment"] = true,
|
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true,
|
||||||
equip = true, hire = true, ["upgrade-batch"] = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
|
hire = true, ["upgrade-batch"] = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
|
||||||
|
|
||||||
-- Sounds per action kind (fx.js); claim and sell sound as their numbers arrive.
|
-- Sounds per action kind (fx.js); claim and sell sound as their numbers arrive.
|
||||||
local SOUND = {
|
local SOUND = {
|
||||||
["buy-tool"] = "buy", ["buy-attachment"] = "buy", equip = "upgrade", unequip = "toggle",
|
["buy-tool"] = "buy",
|
||||||
hire = "confirm", ["seat-advisor"] = "upgrade", ["unseat-advisor"] = "toggle", ["swap-advisors"] = "toggle",
|
hire = "confirm", ["seat-advisor"] = "upgrade", ["unseat-advisor"] = "toggle", ["swap-advisors"] = "toggle",
|
||||||
["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade", ["upgrade-batch"] = "upgrade",
|
["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade", ["upgrade-batch"] = "upgrade",
|
||||||
["buy-moon"] = "moon", ["sell-moon"] = "shimmer", stake = "deploy", redrill = "deploy",
|
["buy-moon"] = "moon", ["sell-moon"] = "shimmer", stake = "deploy", redrill = "deploy",
|
||||||
refine = "refine", collect = "collect", error = "error",
|
refine = "refine", collect = "collect", error = "error",
|
||||||
-- (sell-tool and sell-attachment: the inline confirm's (cards.ask_button), then the cash as the coins land)
|
-- (sell-tool: the inline confirm's (cards.ask_button), then the cash as the coins land)
|
||||||
}
|
}
|
||||||
|
|
||||||
local function v3(x, y)
|
local function v3(x, y)
|
||||||
@@ -1015,9 +1015,27 @@ function M:batch_ready(g, jobs)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- (a text node's position is its pivot point and its size not its text's: the box is the
|
||||||
|
-- text's own, round that point)
|
||||||
|
local PIVOT = { -- (fraction of the box left of / above the pivot point)
|
||||||
|
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
|
||||||
|
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
|
||||||
|
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
|
||||||
|
}
|
||||||
|
|
||||||
function M:box_of(key)
|
function M:box_of(key)
|
||||||
local b = self.binds[key]
|
local b = self.binds[key]
|
||||||
return b and self:rect_of(b.box or b.node)
|
if not b then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if b.box or gui.get_type(b.node) ~= gui.TYPE_TEXT then
|
||||||
|
return self:rect_of(b.box or b.node)
|
||||||
|
end
|
||||||
|
local r = self:rect_of(b.node)
|
||||||
|
local k = PIVOT[gui.get_pivot(b.node)] or { 0, 0 }
|
||||||
|
local w = ui.text_width(b.node)
|
||||||
|
local h = r.h * gui.get_scale(b.node).y
|
||||||
|
return { x = r.x - k[1] * w, y = r.y - k[2] * h, w = w, h = h }
|
||||||
end
|
end
|
||||||
|
|
||||||
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
-- (render/mm.render_script) draws the scene into an HDR target and binds it as
|
-- (render/mm.render_script) draws the scene into an HDR target and binds it as
|
||||||
-- `moon_tex` for the GUI node's material, which tone maps it (ACES, like three.js).
|
-- `moon_tex` for the GUI node's material, which tone maps it (ACES, like three.js).
|
||||||
--
|
--
|
||||||
-- Settings: GET /api/moon-settings (the values tuned on /utils/moons), merged over the
|
-- Settings: GET /api/moon-settings (the values tuned in the moon lab), merged over the
|
||||||
-- defaults like the web does; until the request answers nothing is generated.
|
-- defaults like the web does; until the request answers nothing is generated.
|
||||||
--
|
--
|
||||||
-- Debug config: mm.moon_id (show this id instead), mm.moon_spin=0 (no spin, fixed angle).
|
-- Debug config: mm.moon_id (show this id instead), mm.moon_spin=0 (no spin, fixed angle).
|
||||||
@@ -38,10 +38,17 @@ local M = {}
|
|||||||
M.id = nil
|
M.id = nil
|
||||||
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
|
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
|
||||||
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
|
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
|
||||||
|
M.covered = false -- a dialog is open over the page: the viewport takes no drag or wheel
|
||||||
|
|
||||||
-- Settings: nil until loaded (or failed: then the defaults).
|
-- Settings: nil until loaded (or failed: then the defaults). Replace the table to change
|
||||||
|
-- them (moon.script compares by identity; the moon lab does, and Save sets the saved ones).
|
||||||
M.settings = nil
|
M.settings = nil
|
||||||
|
|
||||||
|
-- Written by moon.script: a mesh is being made (moon.building), and the last one made
|
||||||
|
-- ({ ms, vertices, technique }, moon.last_build). The moon lab paces its rebuilds by them.
|
||||||
|
M.building = false
|
||||||
|
M.last_build = nil
|
||||||
|
|
||||||
-- What the render script draws, written by moon.script every frame.
|
-- What the render script draws, written by moon.script every frame.
|
||||||
M.frame = { visible = false, has_mesh = false }
|
M.frame = { visible = false, has_mesh = false }
|
||||||
|
|
||||||
@@ -67,9 +74,12 @@ local function node_rect(node)
|
|||||||
gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
|
gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
|
||||||
local br = gui.get_screen_position(probe)
|
local br = gui.get_screen_position(probe)
|
||||||
gui.delete_node(probe)
|
gui.delete_node(probe)
|
||||||
local x0, x1 = math.floor(tl.x + 0.5), math.floor(br.x + 0.5)
|
-- The size is rounded once (not as the difference of two rounded corners), so it stays
|
||||||
local y0, y1 = math.floor(br.y + 0.5), math.floor(tl.y + 0.5)
|
-- the same while the node moves by fractions of a pixel: every new size remakes the
|
||||||
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x)
|
-- render targets.
|
||||||
|
local w, h = math.floor(br.x - tl.x + 0.5), math.floor(tl.y - br.y + 0.5)
|
||||||
|
local x0, y0 = math.floor(tl.x + 0.5), math.floor(br.y + 0.5)
|
||||||
|
return { x = x0, y = y0, w = w, h = h }, w / math.max(1, size.x)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function set_node(node, quiet)
|
local function set_node(node, quiet)
|
||||||
|
|||||||
+81
-53
@@ -53,6 +53,12 @@ function M.copy(st)
|
|||||||
return copy(st, true)
|
return copy(st, true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Copies of a tool: the same store item and track levels (tools, well.idle groups).
|
||||||
|
local function same_copy(a, b)
|
||||||
|
return a.storeItemId == b.storeItemId and (a.yieldLevel or 0) == (b.yieldLevel or 0)
|
||||||
|
and (a.cycleLevel or 0) == (b.cycleLevel or 0)
|
||||||
|
end
|
||||||
|
|
||||||
local function find(list, id, key)
|
local function find(list, id, key)
|
||||||
key = key or "id"
|
key = key or "id"
|
||||||
for i, x in ipairs(list or {}) do
|
for i, x in ipairs(list or {}) do
|
||||||
@@ -168,7 +174,7 @@ function M.tool(st, id)
|
|||||||
return find(st.tools, id)
|
return find(st.tools, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The tool to deploy for id: it, or an idle copy of it (same store item and tier).
|
-- The tool to deploy for id: it, or an idle copy of it (same store item and track levels).
|
||||||
function M.idle_copy(st, id)
|
function M.idle_copy(st, id)
|
||||||
local t = find(st.tools, id)
|
local t = find(st.tools, id)
|
||||||
if not t then
|
if not t then
|
||||||
@@ -178,7 +184,7 @@ function M.idle_copy(st, id)
|
|||||||
return t
|
return t
|
||||||
end
|
end
|
||||||
for _, x in ipairs(st.tools) do
|
for _, x in ipairs(st.tools) do
|
||||||
if not x.stakeId and x.storeItemId == t.storeItemId and x.tier == t.tier then
|
if not x.stakeId and same_copy(x, t) then
|
||||||
return x
|
return x
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -196,31 +202,21 @@ end
|
|||||||
-- earlier ones left. Wells are indexes into moon.wells (the server's order).
|
-- earlier ones left. Wells are indexes into moon.wells (the server's order).
|
||||||
local EPS = 1e-9
|
local EPS = 1e-9
|
||||||
|
|
||||||
-- The wells a stake with this reach mines: its anchor first, then the richest (ties by
|
-- The wells a stake with this reach mines: its anchor first, then the next wells in the
|
||||||
-- order), leaving out empty ones.
|
-- moon's order (wrapping round), leaving out empty ones (game.go coverWells).
|
||||||
local function cover_wells(anchor, supply, reach)
|
local function cover_wells(anchor, supply, reach)
|
||||||
local others = {}
|
local n = #supply
|
||||||
for i, x in ipairs(supply) do
|
local start = (anchor and anchor >= 1 and anchor <= n) and anchor or 1
|
||||||
if i ~= anchor and x > 0 then
|
|
||||||
others[#others + 1] = i
|
|
||||||
end
|
|
||||||
end
|
|
||||||
table.sort(others, function(a, b)
|
|
||||||
if supply[a] ~= supply[b] then
|
|
||||||
return supply[a] > supply[b]
|
|
||||||
end
|
|
||||||
return a < b
|
|
||||||
end)
|
|
||||||
local out = {}
|
local out = {}
|
||||||
if anchor and supply[anchor] and supply[anchor] > 0 then
|
local want = math.max(1, reach or 1)
|
||||||
out[1] = anchor
|
for k = 0, n - 1 do
|
||||||
end
|
if #out >= want then
|
||||||
for _, i in ipairs(others) do
|
break
|
||||||
out[#out + 1] = i
|
end
|
||||||
end
|
local i = (start - 1 + k) % n + 1
|
||||||
local n = math.min(#out, math.max(1, reach or 1))
|
if supply[i] > 0 then
|
||||||
for i = #out, n + 1, -1 do
|
out[#out + 1] = i
|
||||||
out[i] = nil
|
end
|
||||||
end
|
end
|
||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
@@ -366,11 +362,11 @@ function M.add_stake(st, tool, moon_id, well, ctx, group)
|
|||||||
-- Off the selected well's idle list.
|
-- Off the selected well's idle list.
|
||||||
if st.well then
|
if st.well then
|
||||||
for i, g in ipairs(st.well.idle or {}) do
|
for i, g in ipairs(st.well.idle or {}) do
|
||||||
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
|
if same_copy(g, tool) then
|
||||||
g.count = g.count - 1
|
g.count = g.count - 1
|
||||||
if g.toolId == tool.id then
|
if g.toolId == tool.id then
|
||||||
for _, x in ipairs(st.tools) do
|
for _, x in ipairs(st.tools) do
|
||||||
if not x.stakeId and x.storeItemId == g.storeItemId and x.tier == g.tier then
|
if not x.stakeId and same_copy(x, g) then
|
||||||
g.toolId = x.id
|
g.toolId = x.id
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
@@ -414,7 +410,7 @@ function M.remove_stake(st, s)
|
|||||||
if st.well and st.well.idle then
|
if st.well and st.well.idle then
|
||||||
local g = nil
|
local g = nil
|
||||||
for _, x in ipairs(st.well.idle) do
|
for _, x in ipairs(st.well.idle) do
|
||||||
if x.storeItemId == tool.storeItemId and x.tier == tool.tier then
|
if same_copy(x, tool) then
|
||||||
g = x
|
g = x
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -424,9 +420,9 @@ function M.remove_stake(st, s)
|
|||||||
local free = M.supply(st, st.well.id)
|
local free = M.supply(st, st.well.id)
|
||||||
local y = tool.yield * yield_boost(st, st.well.resource)
|
local y = tool.yield * yield_boost(st, st.well.resource)
|
||||||
st.well.idle[#st.well.idle + 1] = {
|
st.well.idle[#st.well.idle + 1] = {
|
||||||
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, tier = tool.tier, count = 1,
|
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, yieldLevel = tool.yieldLevel, cycleLevel = tool.cycleLevel, count = 1,
|
||||||
cycleSecs = tool.cycleSecs, yield = y, reach = tool.reach, here = math.min(y, free), free = free,
|
cycleSecs = tool.cycleSecs, yield = y, reach = tool.reach, here = math.min(y, free), free = free,
|
||||||
short = free < y, attachments = tool.attachments or {},
|
short = free < y,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -586,17 +582,17 @@ end
|
|||||||
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
|
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
|
||||||
-- count goes up (the server's LastWell).
|
-- count goes up (the server's LastWell).
|
||||||
M.register("claim", function(st, p, ctx)
|
M.register("claim", function(st, p, ctx)
|
||||||
if p["claim-wide"] then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
local list = {}
|
local list = {}
|
||||||
for _, s in ipairs(st.stakes) do
|
for _, s in ipairs(st.stakes) do
|
||||||
local wide = (s.reach or 1) > 1
|
local wide = (s.reach or 1) > 1
|
||||||
if done(s, ctx) and (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
|
local scope
|
||||||
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then
|
if p["claim-wide"] then
|
||||||
if wide then
|
scope = wide and s.moonId == p["claim-wide"]
|
||||||
return false -- multi-well drills: the server splits them
|
else
|
||||||
end
|
scope = (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
|
||||||
|
and (not p["claim-moon"] or s.moonId == p["claim-moon"])
|
||||||
|
end
|
||||||
|
if done(s, ctx) and scope then
|
||||||
list[#list + 1] = s
|
list[#list + 1] = s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -604,14 +600,33 @@ M.register("claim", function(st, p, ctx)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
-- Oldest first, each from what the earlier claims left (claimInTx: at most the supply).
|
-- Oldest first, each from what the earlier claims left (claimInTx: at most the supply).
|
||||||
|
-- A multi-well drill takes from its well, then the moon's next ones (split).
|
||||||
table.sort(list, function(a, b) return a.start < b.start end)
|
table.sort(list, function(a, b) return a.start < b.start end)
|
||||||
local emptied = {}
|
local emptied = {}
|
||||||
for _, s in ipairs(list) do
|
for _, s in ipairs(list) do
|
||||||
local take = math.max(0, math.min(s.amount or s.take or 0, M.supply(st, s.wellId)))
|
local moon = find(st.moons, s.moonId)
|
||||||
M.add_item(st, s.resource, take * M.skew)
|
if (s.reach or 1) > 1 and moon then
|
||||||
M.add_supply(st, s.wellId, -take)
|
local sup = {}
|
||||||
if take > 0 and M.supply(st, s.wellId) <= 0 then
|
for i, w in ipairs(moon.wells or {}) do
|
||||||
emptied[s.wellId] = true
|
sup[i] = M.supply(st, w.id)
|
||||||
|
end
|
||||||
|
for i, take in ipairs(split(moon, s, sup)) do
|
||||||
|
local w = moon.wells[i]
|
||||||
|
if take > 0 then
|
||||||
|
M.add_item(st, w.resource or s.resource, take * M.skew)
|
||||||
|
M.add_supply(st, w.id, -take)
|
||||||
|
if M.supply(st, w.id) <= 0 then
|
||||||
|
emptied[w.id] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local take = math.max(0, math.min(s.amount or s.take or 0, M.supply(st, s.wellId)))
|
||||||
|
M.add_item(st, s.resource, take * M.skew)
|
||||||
|
M.add_supply(st, s.wellId, -take)
|
||||||
|
if take > 0 and M.supply(st, s.wellId) <= 0 then
|
||||||
|
emptied[s.wellId] = true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
|
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
|
||||||
end
|
end
|
||||||
@@ -627,10 +642,17 @@ M.register("claim", function(st, p, ctx)
|
|||||||
for _, s in ipairs(list) do
|
for _, s in ipairs(list) do
|
||||||
local tool = M.tool(st, s.toolId)
|
local tool = M.tool(st, s.toolId)
|
||||||
local w = wells(st, s.wellId)[1]
|
local w = wells(st, s.wellId)[1]
|
||||||
local group = tool and w and st.well and st.well.id == w.id and find(st.well.idle, tool.storeItemId, "storeItemId")
|
local group = nil
|
||||||
|
if tool and w and st.well and st.well.id == w.id then
|
||||||
|
for _, g in ipairs(st.well.idle or {}) do
|
||||||
|
if same_copy(g, tool) then
|
||||||
|
group = g
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
local amount = tool and w and (group and group.yield or tool.yield * yield_boost(st, w.resource))
|
local amount = tool and w and (group and group.yield or tool.yield * yield_boost(st, w.resource))
|
||||||
-- Not on a drained or fully committed well (the server leaves the tool idle).
|
-- Not on a drained or fully committed well (the server leaves the tool idle).
|
||||||
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, 1) or 1) > EPS then
|
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, tool.reach or 1) or 1) > EPS then
|
||||||
M.add_stake(st, tool, s.moonId, w, ctx, group)
|
M.add_stake(st, tool, s.moonId, w, ctx, group)
|
||||||
local detail = st.moon and find(st.moon.wells, s.wellId)
|
local detail = st.moon and find(st.moon.wells, s.wellId)
|
||||||
if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
|
if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
|
||||||
@@ -661,20 +683,20 @@ M.register("stake", function(st, p, ctx)
|
|||||||
return false -- "Deploy there": the view moves to the other well, wait for it
|
return false -- "Deploy there": the view moves to the other well, wait for it
|
||||||
end
|
end
|
||||||
local tool = M.idle_copy(st, p.tool)
|
local tool = M.idle_copy(st, p.tool)
|
||||||
if not tool or (tool.reach or 1) > 1 then
|
if not tool then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local group = nil
|
local group = nil
|
||||||
if st.well and st.well.id == w.id then
|
if st.well and st.well.id == w.id then
|
||||||
for _, g in ipairs(st.well.idle or {}) do
|
for _, g in ipairs(st.well.idle or {}) do
|
||||||
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
|
if same_copy(g, tool) then
|
||||||
group = g
|
group = g
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- Refused when the drills on the well take all it has left (fullyCommitted).
|
-- Refused when the drills on the well take all it has left (fullyCommitted).
|
||||||
local amount = group and group.yield or tool.yield * yield_boost(st, w.resource)
|
local amount = group and group.yield or tool.yield * yield_boost(st, w.resource)
|
||||||
if (M.room(st, w.id, amount, 1) or 0) <= EPS then
|
if (M.room(st, w.id, amount, tool.reach or 1) or 0) <= EPS then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
M.add_stake(st, tool, moon.id, w, ctx, group)
|
M.add_stake(st, tool, moon.id, w, ctx, group)
|
||||||
@@ -705,7 +727,7 @@ M.register("redrill", function(st, p, ctx)
|
|||||||
local function group_of(t)
|
local function group_of(t)
|
||||||
if st.well and st.well.id == id then
|
if st.well and st.well.id == id then
|
||||||
for _, g in ipairs(st.well.idle or {}) do
|
for _, g in ipairs(st.well.idle or {}) do
|
||||||
if g.storeItemId == t.storeItemId and g.tier == t.tier then
|
if same_copy(g, t) then
|
||||||
return g
|
return g
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -717,16 +739,22 @@ M.register("redrill", function(st, p, ctx)
|
|||||||
if left <= EPS then
|
if left <= EPS then
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
local pick
|
-- What each takes a claim, with the well's yield event (the server's t.Amount).
|
||||||
|
local amounts = {}
|
||||||
for i, t in ipairs(tools) do
|
for i, t in ipairs(tools) do
|
||||||
if t.yield >= left - EPS and (not pick or t.yield < tools[pick].yield) then
|
local g = group_of(t)
|
||||||
|
amounts[i] = g and g.yield or t.yield * yield_boost(st, w.resource)
|
||||||
|
end
|
||||||
|
local pick
|
||||||
|
for i, a in ipairs(amounts) do
|
||||||
|
if a >= left - EPS and (not pick or a < amounts[pick]) then
|
||||||
pick = i
|
pick = i
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not pick then
|
if not pick then
|
||||||
pick = 1
|
pick = 1
|
||||||
for i, t in ipairs(tools) do
|
for i, a in ipairs(amounts) do
|
||||||
if t.yield > tools[pick].yield then
|
if a > amounts[pick] then
|
||||||
pick = i
|
pick = i
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+22
-11
@@ -8,8 +8,8 @@
|
|||||||
-- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>",
|
-- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>",
|
||||||
-- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells
|
-- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells
|
||||||
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel" /
|
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel" /
|
||||||
-- "dialog:<...>", "list:active|past", equipment cards "drill:<tool id>", buttons that ask
|
-- "dialog:<...>", "list:active|past", equipment rows "drill:<tool id>", buttons that ask
|
||||||
-- first "confirm:<action>", the gear "settings" (its menu: "mute", "theme", "motion",
|
-- first "confirm:<action>" (a row's Sell "confirm:sell-tool:<tool id>"), the gear "settings" (its menu: "mute", "theme", "motion",
|
||||||
-- "grant", "menu:help", "menu:save-code", "menu:restore"), "idle-copy", "better-well". A
|
-- "grant", "menu:help", "menu:save-code", "menu:restore"), "idle-copy", "better-well". A
|
||||||
-- component given opts.id uses that instead.
|
-- component given opts.id uses that instead.
|
||||||
--
|
--
|
||||||
@@ -96,7 +96,13 @@ function M.register(id, node, kind, label, zone, no_focus)
|
|||||||
if not M.enabled or not id then
|
if not M.enabled or not id then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if elements[id] then
|
local old = elements[id]
|
||||||
|
if old and not pcall(gui.get_id, old.node) then
|
||||||
|
-- (its node went: a long list's row scrolled out and back keeps its plain id)
|
||||||
|
old.node, old.kind, old.label, old.zone, old.scope = node, kind or "button", label, zone, scope
|
||||||
|
return id
|
||||||
|
end
|
||||||
|
if old then
|
||||||
local n = 2
|
local n = 2
|
||||||
while elements[id .. "#" .. n] do
|
while elements[id .. "#" .. n] do
|
||||||
n = n + 1
|
n = n + 1
|
||||||
@@ -198,10 +204,11 @@ local function visible(node)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A run of commands spread over frames: { frame_fn, ... }.
|
-- A run of commands spread over frames: { frame_fn, ... }. id: the command's, for the
|
||||||
local function steps(g, list, done)
|
-- error reply when a step fails.
|
||||||
|
local function steps(g, list, done, id)
|
||||||
g.test_steps = g.test_steps or {}
|
g.test_steps = g.test_steps or {}
|
||||||
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done }
|
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done, id = id }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- mm.test_dir (drive.py passes its output folder): replies go to files there.
|
-- mm.test_dir (drive.py passes its output folder): replies go to files there.
|
||||||
@@ -343,7 +350,7 @@ function COMMANDS.click(g, cmd, input)
|
|||||||
}
|
}
|
||||||
steps(g, list, function()
|
steps(g, list, function()
|
||||||
reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
|
reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
|
||||||
end)
|
end, cmd.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds.
|
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds.
|
||||||
@@ -385,7 +392,7 @@ function COMMANDS.key(g, cmd, input)
|
|||||||
function() input(k, { released = true }) end,
|
function() input(k, { released = true }) end,
|
||||||
}, function()
|
}, function()
|
||||||
reply(cmd.id, { ok = true })
|
reply(cmd.id, { ok = true })
|
||||||
end)
|
end, cmd.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
function COMMANDS.type(g, cmd, input)
|
function COMMANDS.type(g, cmd, input)
|
||||||
@@ -605,7 +612,7 @@ function COMMANDS.dump(g, cmd)
|
|||||||
steps(g, { function() end, function()
|
steps(g, { function() end, function()
|
||||||
local ok, d = pcall(dump, g)
|
local ok, d = pcall(dump, g)
|
||||||
reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) })
|
reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) })
|
||||||
end })
|
end }, nil, cmd.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
function COMMANDS.state(g)
|
function COMMANDS.state(g)
|
||||||
@@ -692,9 +699,13 @@ function M.update(g, input, api)
|
|||||||
-- Running multi-frame steps.
|
-- Running multi-frame steps.
|
||||||
if g.test_steps and #g.test_steps > 0 then
|
if g.test_steps and #g.test_steps > 0 then
|
||||||
local s = g.test_steps[1]
|
local s = g.test_steps[1]
|
||||||
s.list[s.i]()
|
local ok, err = pcall(s.list[s.i])
|
||||||
s.i = s.i + 1
|
s.i = s.i + 1
|
||||||
if s.i > #s.list then
|
if not ok then
|
||||||
|
-- (a failing step ends its run: reply with the error, or the command never answers)
|
||||||
|
table.remove(g.test_steps, 1)
|
||||||
|
reply(s.id, { ok = false, error = tostring(err) })
|
||||||
|
elseif s.i > #s.list then
|
||||||
table.remove(g.test_steps, 1)
|
table.remove(g.test_steps, 1)
|
||||||
if s.done then
|
if s.done then
|
||||||
s.done()
|
s.done()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- Theme: the web build's HUD look (server/web/css/app.css, dark and light) as Lua values.
|
-- Theme: the web build's HUD look (the old web client, dark and light) as Lua values.
|
||||||
-- Colors are vector4 (r, g, b, a in 0..1), named tokens that theme.set switches in place; fonts are the names declared in the .gui files;
|
-- Colors are vector4 (r, g, b, a in 0..1), named tokens that theme.set switches in place; fonts are the names declared in the .gui files;
|
||||||
-- sizes are in reference pixels (the 1280x800 layout, scaled to the window).
|
-- sizes are in reference pixels (the 1280x800 layout, scaled to the window).
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ M.palettes = {
|
|||||||
loss = { "#ff8a96" },
|
loss = { "#ff8a96" },
|
||||||
teal = { "#2dd4bf" },
|
teal = { "#2dd4bf" },
|
||||||
violet = { "#b69cff" },
|
violet = { "#b69cff" },
|
||||||
violet_hi = { "#dccfff" }, -- attachment and advisor effects
|
violet_hi = { "#dccfff" }, -- advisor effects
|
||||||
stat_up = { "#c4a8ff" }, -- a tool stat its next upgrade raises (not money)
|
stat_up = { "#c4a8ff" }, -- a tool stat its next upgrade raises (not money)
|
||||||
up = { "#3ee6a0" },
|
up = { "#3ee6a0" },
|
||||||
flash = { "#ffffff" }, -- peak of a gain flash or a scan line
|
flash = { "#ffffff" }, -- peak of a gain flash or a scan line
|
||||||
@@ -171,7 +171,7 @@ M.size = {
|
|||||||
market_max = 1320, -- the market's (quotes, detail and ticket)
|
market_max = 1320, -- the market's (quotes, detail and ticket)
|
||||||
market_panel_max = 460, -- tallest the market's detail and ticket panels get
|
market_panel_max = 460, -- tallest the market's detail and ticket panels get
|
||||||
text_max = 720, -- widest a paragraph gets
|
text_max = 720, -- widest a paragraph gets
|
||||||
cards_max = 1400, -- widest a card grid gets (Store, Equipment, Advisors), centered
|
cards_max = 1400, -- widest a card grid gets (Store, Advisors), centered
|
||||||
card_col_min = 250, -- narrowest card column (the web's minmax(250px, 1fr)); more as it widens
|
card_col_min = 250, -- narrowest card column (the web's minmax(250px, 1fr)); more as it widens
|
||||||
card_min_w = 420, -- narrowest card with the image beside the text (narrower: image on top)
|
card_min_w = 420, -- narrowest card with the image beside the text (narrower: image on top)
|
||||||
panel_pad = 14,
|
panel_pad = 14,
|
||||||
|
|||||||
@@ -35,17 +35,13 @@ local KIND = {
|
|||||||
redrill = { "cyan", "▼" },
|
redrill = { "cyan", "▼" },
|
||||||
upgrade = { "cyan", "▲" },
|
upgrade = { "cyan", "▲" },
|
||||||
["buy-tool"] = { "cyan", "+" },
|
["buy-tool"] = { "cyan", "+" },
|
||||||
["buy-attachment"] = { "cyan", "+" },
|
|
||||||
["buy-seat"] = { "cyan", "+" },
|
["buy-seat"] = { "cyan", "+" },
|
||||||
["buy-slot"] = { "cyan", "+" },
|
["buy-slot"] = { "cyan", "+" },
|
||||||
equip = { "cyan", "▲" },
|
|
||||||
unequip = { "muted", "▼" },
|
|
||||||
hire = { "violet", "●" },
|
hire = { "violet", "●" },
|
||||||
refine = { "violet", "◆" },
|
refine = { "violet", "◆" },
|
||||||
collect = { "teal", "◆" },
|
collect = { "teal", "◆" },
|
||||||
["sell-moon"] = { "ready", "₥" },
|
["sell-moon"] = { "ready", "₥" },
|
||||||
["sell-tool"] = { "ready", "₥" },
|
["sell-tool"] = { "ready", "₥" },
|
||||||
["sell-attachment"] = { "ready", "₥" },
|
|
||||||
["save-code"] = { "violet", "★" },
|
["save-code"] = { "violet", "★" },
|
||||||
restore = { "violet", "★" },
|
restore = { "violet", "★" },
|
||||||
error = { "danger", "!" },
|
error = { "danger", "!" },
|
||||||
|
|||||||
+27
-8
@@ -180,7 +180,7 @@ M.button_looks = setmetatable({}, { __mode = "k" })
|
|||||||
-- A button's nodes (components/button.lua gives it behavior: hover, press, busy, clicks;
|
-- A button's nodes (components/button.lua gives it behavior: hover, press, busy, clicks;
|
||||||
-- or H.action / H.link). opts: variant ("primary", "secondary", "redrill", "danger",
|
-- or H.action / H.link). opts: variant ("primary", "secondary", "redrill", "danger",
|
||||||
-- "ghost"; theme.button), primary (= variant "primary"), small, disabled, w, h, price,
|
-- "ghost"; theme.button), primary (= variant "primary"), small, disabled, w, h, price,
|
||||||
-- accent, on (a lit ghost), fill / edge / text (override the variant's colors). Returns
|
-- accent, bare (the price right after the label, no "·"), on (a lit ghost), fill / edge / text (override the variant's colors). Returns
|
||||||
-- root, nodes, width.
|
-- root, nodes, width.
|
||||||
function M.button(parent, label, x, y, opts)
|
function M.button(parent, label, x, y, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
@@ -199,12 +199,16 @@ function M.button(parent, label, x, y, opts)
|
|||||||
local parts = { { text, M.text_width(text) } }
|
local parts = { { text, M.text_width(text) } }
|
||||||
if opts.price then
|
if opts.price then
|
||||||
local accent = opts.accent or v.price
|
local accent = opts.accent or v.price
|
||||||
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
|
|
||||||
gui.set_color(sep, M.color_spec(tc))
|
|
||||||
local sym = M.text(root, theme.currency, { font = "sym", px = px - 1, color = accent }, 0, 0)
|
local sym = M.text(root, theme.currency, { font = "sym", px = px - 1, color = accent }, 0, 0)
|
||||||
local num = M.text(root, M.money(opts.price), { font = "mono", px = px - 1, color = accent }, 0, 0)
|
local num = M.text(root, M.money(opts.price), { font = "mono", px = px - 1, color = accent }, 0, 0)
|
||||||
parts[#parts + 1] = { sep, M.text_width(sep), 8 }
|
if opts.bare then -- (no "·" between: "+ ₥290")
|
||||||
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
|
parts[#parts + 1] = { sym, M.text_width(sym), 5 }
|
||||||
|
else
|
||||||
|
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
|
||||||
|
gui.set_color(sep, M.color_spec(tc))
|
||||||
|
parts[#parts + 1] = { sep, M.text_width(sep), 8 }
|
||||||
|
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
|
||||||
|
end
|
||||||
parts[#parts + 1] = { num, M.text_width(num), 1 }
|
parts[#parts + 1] = { num, M.text_width(num), 1 }
|
||||||
end
|
end
|
||||||
local content = 0
|
local content = 0
|
||||||
@@ -270,8 +274,8 @@ function M.fit_text(node, max_w)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
while #text > 1 do
|
while #text > 1 do
|
||||||
-- (a cut entity like "&am" would show as text)
|
-- (a whole UTF-8 character at a time; a cut entity like "&am" would show as text)
|
||||||
text = text:sub(1, -2):gsub("&[%a#]*$", ""):gsub("%s+$", "")
|
text = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", ""):gsub("&[%a#]*$", ""):gsub("%s+$", "")
|
||||||
gui.set_text(node, text .. "…")
|
gui.set_text(node, text .. "…")
|
||||||
if M.text_width(node) <= max_w then
|
if M.text_width(node) <= max_w then
|
||||||
return
|
return
|
||||||
@@ -333,8 +337,13 @@ function M.dur(ms)
|
|||||||
return string.format("%d:%02d", m, sec)
|
return string.format("%d:%02d", m, sec)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A drill cycle like the web's secs: "4s", "2m", "2m 30s".
|
-- A drill cycle like the web's secs: "4s", "2.6s" (tenths), "2m", "2m 30s".
|
||||||
function M.cycle(secs)
|
function M.cycle(secs)
|
||||||
|
if secs < 60 and secs ~= math.floor(secs) then
|
||||||
|
local t = math.floor(secs * 10 + 0.5) / 10
|
||||||
|
return (t == math.floor(t) and string.format("%d", t) or string.format("%.1f", t)) .. "s"
|
||||||
|
end
|
||||||
|
secs = math.floor(secs + 0.5)
|
||||||
if secs < 60 then
|
if secs < 60 then
|
||||||
return secs .. "s"
|
return secs .. "s"
|
||||||
elseif secs % 60 == 0 then
|
elseif secs % 60 == 0 then
|
||||||
@@ -343,6 +352,16 @@ function M.cycle(secs)
|
|||||||
return string.format("%dm %ds", math.floor(secs / 60), secs % 60)
|
return string.format("%dm %ds", math.floor(secs / 60), secs % 60)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A tool's track levels after its name, like the web's Levels.Tag: " Y2 C1", "" for a
|
||||||
|
-- base tool (both levels 0). t: anything with yieldLevel and cycleLevel.
|
||||||
|
function M.levels_tag(t)
|
||||||
|
local y, c = t and t.yieldLevel or 0, t and t.cycleLevel or 0
|
||||||
|
if y == 0 and c == 0 then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
return " Y" .. y .. " C" .. c
|
||||||
|
end
|
||||||
|
|
||||||
function M.plural(n, word)
|
function M.plural(n, word)
|
||||||
return n .. " " .. word .. (n == 1 and "" or "s")
|
return n .. " " .. word .. (n == 1 and "" or "s")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ local function start_job(self, id, key, params)
|
|||||||
if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
|
if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
|
||||||
params.resolution = WEB_RESOLUTION
|
params.resolution = WEB_RESOLUTION
|
||||||
end
|
end
|
||||||
local job = { id = id, key = key, params = params }
|
local job = { id = id, key = key, params = params, t0 = now() }
|
||||||
job.gen = native.new(terrain.native_params(params))
|
job.gen = native.new(terrain.native_params(params))
|
||||||
job.threaded = native.start(job.gen)
|
job.threaded = native.start(job.gen)
|
||||||
self.job = job
|
self.job = job
|
||||||
@@ -252,7 +252,9 @@ end
|
|||||||
local function job_done(self, job)
|
local function job_done(self, job)
|
||||||
local res = new_buffer_resource(job.buffer)
|
local res = new_buffer_resource(job.buffer)
|
||||||
cache_put(self, job.key, res)
|
cache_put(self, job.key, res)
|
||||||
print(string.format("moon: %s (%s) built, %d vertices", job.id, job.params.technique, job.vertices))
|
local ms = (now() - job.t0) * 1000
|
||||||
|
moon.last_build = { ms = ms, vertices = job.vertices, technique = job.params.technique }
|
||||||
|
print(string.format("moon: %s (%s) built, %d vertices, %.0f ms", job.id, job.params.technique, job.vertices, ms))
|
||||||
if job.key == self.want_key then
|
if job.key == self.want_key then
|
||||||
set_mesh(self, job.key, res, job.params)
|
set_mesh(self, job.key, res, job.params)
|
||||||
end
|
end
|
||||||
@@ -450,6 +452,7 @@ function update(self, dt)
|
|||||||
if #self.orphans > 0 then
|
if #self.orphans > 0 then
|
||||||
step_orphans(self)
|
step_orphans(self)
|
||||||
end
|
end
|
||||||
|
moon.building = self.job ~= nil or #self.orphans > 0
|
||||||
if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
|
if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
|
||||||
f.visible = false
|
f.visible = false
|
||||||
return
|
return
|
||||||
@@ -602,7 +605,7 @@ function update(self, dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function on_input(self, action_id, action)
|
function on_input(self, action_id, action)
|
||||||
local rect = moon.frame.visible and moon.rect
|
local rect = moon.frame.visible and not moon.covered and moon.rect
|
||||||
if not rect then
|
if not rect then
|
||||||
self.dragging = false
|
self.dragging = false
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
-- Admin area: tools for tuning the game, opened from the gear menu (settings.lua) for
|
||||||
|
-- admins (state.admin) and in any debug build. While g.admin names a screen, core/main
|
||||||
|
-- builds it under the header instead of the moon list, the section and the aside; a bar
|
||||||
|
-- on top lists the screens and leads back to the game (so does any navigation: a header
|
||||||
|
-- tab, Back).
|
||||||
|
--
|
||||||
|
-- A screen module: build(g, parent, x, y, w, h), sig(g) (its block is kept while it and
|
||||||
|
-- the size stay the same), optional open(g), close(g), update(g, dt). Add one to SCREENS.
|
||||||
|
-- Test ids: "admin:<screen>", "admin:back".
|
||||||
|
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
local theme = require("modules.theme")
|
||||||
|
local button = require("components.button")
|
||||||
|
|
||||||
|
local S = theme.size
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local SCREENS = {
|
||||||
|
{ id = "moonlab", label = "Moon lab", mod = require("screens.game.moonlab") },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function find(id)
|
||||||
|
for _, s in ipairs(SCREENS) do
|
||||||
|
if s.id == id then
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.allowed(g)
|
||||||
|
return (g.state and g.state.admin) or sys.get_engine_info().is_debug
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.open(g, id)
|
||||||
|
local s = find(id or SCREENS[1].id)
|
||||||
|
if not s then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if g.admin and g.admin ~= s.id then
|
||||||
|
M.close(g)
|
||||||
|
end
|
||||||
|
if g.admin ~= s.id then
|
||||||
|
g.admin = s.id
|
||||||
|
if s.mod.open then
|
||||||
|
s.mod.open(g)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
g.dirty, g.dirty_all = true, true
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.close(g)
|
||||||
|
local s = g.admin and find(g.admin)
|
||||||
|
if s and s.mod.close then
|
||||||
|
s.mod.close(g)
|
||||||
|
end
|
||||||
|
g.admin = nil
|
||||||
|
g.dirty, g.dirty_all = true, true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The block's inputs besides the rect.
|
||||||
|
function M.sig(g)
|
||||||
|
local s = find(g.admin)
|
||||||
|
return { g.admin, s and s.mod.sig and s.mod.sig(g) or false }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The bar and the screen in (x, y, w, h).
|
||||||
|
function M.build(g, parent, x, y, w, h)
|
||||||
|
local s = find(g.admin)
|
||||||
|
local kids = { fg.label(parent, "Admin", "cap", S.button_sm_h, { padding_right = 8 }) }
|
||||||
|
for _, sc in ipairs(SCREENS) do
|
||||||
|
kids[#kids + 1] = fg.button(parent, sc.label, { small = true, variant = sc.id == g.admin and "primary" or nil },
|
||||||
|
function(b, nodes)
|
||||||
|
button.bind(g, b, nodes, { id = "admin:" .. sc.id, on_click = function() M.open(g, sc.id) end })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
kids[#kids + 1] = fg.fill()
|
||||||
|
kids[#kids + 1] = fg.button(parent, "Back to the game", { small = true }, function(b, nodes)
|
||||||
|
button.bind(g, b, nodes, { id = "admin:back", on_click = function() M.close(g) end })
|
||||||
|
end)
|
||||||
|
local bar = fg.row({ width = w, height = S.button_sm_h, gap = 8, align = "center" }, kids)
|
||||||
|
flex.solve(bar, x, y)
|
||||||
|
fg.build(bar, parent)
|
||||||
|
local top = y + bar.h + S.gap
|
||||||
|
if s then
|
||||||
|
s.mod.build(g, parent, x, top, w, y + h - top)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.update(g, dt)
|
||||||
|
local s = g.admin and find(g.admin)
|
||||||
|
if s and s.mod.update then
|
||||||
|
s.mod.update(g, dt)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
+188
-287
@@ -1,8 +1,10 @@
|
|||||||
-- The aside (region, right column): the moon viewport (the selected moon in every
|
-- The aside (region, right column): the moon viewport (the selected moon in every
|
||||||
-- section, the shroud on New moon, a grid when there is none) and under it the selected
|
-- section, the shroud on New moon, a grid when there is none) and under it the selected
|
||||||
-- well's panel (supply, claim, redrill, deploy cards, drills), or what the section puts
|
-- well's facts (supply, what the drills leave, price, its drills and their times), the
|
||||||
-- there (a section module's aside / aside_below, see core/main.lua), always under the
|
-- moon's when no well is picked, or what the section puts there (a section module's
|
||||||
-- viewport.
|
-- aside / aside_below, see core/main.lua), always under the viewport. On the Moons
|
||||||
|
-- section it only shows, but for destructive actions (Sell moon, at its foot): every
|
||||||
|
-- other action is in the main panel (moons.lua's well cards).
|
||||||
|
|
||||||
local theme = require("modules.theme")
|
local theme = require("modules.theme")
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
@@ -10,19 +12,18 @@ local moon_view = require("modules.moon.moon")
|
|||||||
local reveal = require("screens.game.reveal")
|
local reveal = require("screens.game.reveal")
|
||||||
local moons = require("screens.game.moons")
|
local moons = require("screens.game.moons")
|
||||||
local moon_sale = require("screens.game.moon_sale")
|
local moon_sale = require("screens.game.moon_sale")
|
||||||
|
local predict = require("modules.predict")
|
||||||
local panel = require("components.panel")
|
local panel = require("components.panel")
|
||||||
local scroll = require("components.scroll")
|
local scroll = require("components.scroll")
|
||||||
local bar = require("components.bar")
|
local bar = require("components.bar")
|
||||||
local tooltip = require("modules.tooltip")
|
local tooltip = require("modules.tooltip")
|
||||||
local row_c = require("components.row")
|
local row_c = require("components.row")
|
||||||
local button = require("components.button")
|
|
||||||
local chip = require("components.chip")
|
local chip = require("components.chip")
|
||||||
local anim = require("modules.anim")
|
|
||||||
local images = require("modules.images")
|
local images = require("modules.images")
|
||||||
local cards = require("screens.game.cards")
|
|
||||||
local ready = require("screens.game.core.ready")
|
|
||||||
local flex = require("modules.flex")
|
local flex = require("modules.flex")
|
||||||
local fg = require("modules.flex_gui")
|
local fg = require("modules.flex_gui")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
local anim = require("modules.anim")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
|
|
||||||
@@ -94,45 +95,8 @@ local function reach_short(st, n)
|
|||||||
return pick and pick.short ~= "" and pick.short or (n .. " wells")
|
return pick and pick.short ~= "" and pick.short or (n .. " wells")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The long wording of a reach (catalog.reach label): "Mines 3 wells", "Strips the whole
|
|
||||||
-- moon"; "" for one.
|
|
||||||
local function reach_label(st, n)
|
|
||||||
local list = st.catalog and st.catalog.reach or {}
|
|
||||||
local pick = list[#list]
|
|
||||||
for _, r in ipairs(list) do
|
|
||||||
if r.reach == n then
|
|
||||||
pick = r
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return pick and pick.label or ""
|
|
||||||
end
|
|
||||||
|
|
||||||
-- An attachment's effect ("+20% speed", catalog), for its tag's tooltip.
|
|
||||||
local function effect_of(st, name)
|
|
||||||
for _, a in ipairs(st.catalog and st.catalog.attachments or {}) do
|
|
||||||
if a.name == name then
|
|
||||||
return a.effectText
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||||
|
|
||||||
-- The width of a text in a style (a probe).
|
|
||||||
local function text_w(c, str, style)
|
|
||||||
local t = ui.text(c, str, style, 0, 0)
|
|
||||||
local w = ui.text_width(t)
|
|
||||||
gui.delete_node(t)
|
|
||||||
return w
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A text link (H.text_link) as a box h tall.
|
|
||||||
local function link_box(g, c, H, str, style, h, v, id)
|
|
||||||
return made({ width = text_w(c, str, style), height = h, shrink = 0 }, function(p, x, y)
|
|
||||||
H.text_link(g, p, str, style, x, y + h / 2, v, id)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A stat line (H.fact: cap label, value at the right; 20 tall).
|
-- A stat line (H.fact: cap label, value at the right; 20 tall).
|
||||||
local function fact(H, label, value, opts)
|
local function fact(H, label, value, opts)
|
||||||
return made({ height = 20 }, function(p, x, y, w)
|
return made({ height = 20 }, function(p, x, y, w)
|
||||||
@@ -145,115 +109,24 @@ local M11 = { font = "mono", px = 11, color = "muted" }
|
|||||||
local M12 = { font = "mono", px = 12, color = "muted" }
|
local M12 = { font = "mono", px = 12, color = "muted" }
|
||||||
local S12 = { font = "sans", px = 12, color = "muted" }
|
local S12 = { font = "sans", px = 12, color = "muted" }
|
||||||
|
|
||||||
-- Deploy cards: one per idle tool group (well.idle), in a grid (app.css .deploy-cards:
|
-- Selling the moon (once the store is open): a line, then the sale (moon_sale.lua). Tree
|
||||||
-- 170 px columns, 8 apart). A card is the tool's picture on the dark media strip (3:2,
|
-- pieces for the foot of the well or moon facts.
|
||||||
-- count and attachment pips over it), one line of name and tier, one of what a cycle gets
|
local function sale_kids(g, c, moon, inner, H)
|
||||||
-- and Deploy, and a line kept for the short note: every card is the same height, so a
|
if not (g.state.unlocked.store and moon.sale) then
|
||||||
-- deploy never moves the cards after it (only a used-up group's card goes; the next one
|
return {}
|
||||||
-- slides into its spot). A tool that would get less than its yield here has an amber
|
end
|
||||||
-- edge; one that would get nothing (the well is spoken for) is also dimmed to .6 (.85
|
return {
|
||||||
-- under the mouse) by a veil in the panel's color (.is-short / .is-spoken). The card is a
|
space(0, 14),
|
||||||
-- button built at its width; its content is a tree solved inside it.
|
made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end),
|
||||||
local DEPLOY_MIN, DEPLOY_GAP = 170, 8
|
space(0, 12),
|
||||||
local function deploy_media_h(cw)
|
moon_sale.box(g, H, c, moon, inner),
|
||||||
return math.floor(cw * 2 / 3)
|
}
|
||||||
end
|
|
||||||
local function deploy_card_h(cw)
|
|
||||||
return deploy_media_h(cw) + 1 + 7 + 16 + 3 + 16 + 3 + 14 + 8
|
|
||||||
end
|
|
||||||
local function deploy_card(g, c, well, grp, H, cw)
|
|
||||||
local hgt = deploy_card_h(cw)
|
|
||||||
local mh = deploy_media_h(cw)
|
|
||||||
local spoken = grp.short and grp.here <= 0
|
|
||||||
local edge = grp.short and { "ready", spoken and 0.35 or 0.45 } or nil
|
|
||||||
return made({ height = hgt }, function(p, x, y, inner)
|
|
||||||
-- blocked: the drills here take all it has left; the server refuses the stake.
|
|
||||||
local b, bn = ui.button(p, "", x, y, { w = inner, small = true, edge = edge, disabled = grp.blocked })
|
|
||||||
gui.set_size(b, vec(inner, hgt))
|
|
||||||
gui.set_size(ui.part(bn, "button", "edge"), vec(inner, hgt))
|
|
||||||
local tool = find(g.state.tools, grp.toolId)
|
|
||||||
local n_att = #(grp.attachments or {})
|
|
||||||
local gets_s = "+" .. ui.money(grp.here) .. " / " .. ui.cycle(grp.cycleSecs)
|
|
||||||
local reach = (grp.reach or 1) > 1 and fg.label(b, "· " .. reach_short(g.state, grp.reach), S12, 16, { fit = true, shrink = 1 }) or nil
|
|
||||||
if reach then
|
|
||||||
local label = reach_label(g.state, grp.reach)
|
|
||||||
if label ~= "" then
|
|
||||||
reach.done = function(n) tooltip.add(n, label) end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local gets = fg.label(b, gets_s, M12, 16, { shrink = 0 })
|
|
||||||
gets.done = function(n)
|
|
||||||
tooltip.add(n, "What a cycle gets here after the drills already on it"
|
|
||||||
.. (grp.short and (" (its yield is " .. ui.money(grp.yield) .. ")") or ""))
|
|
||||||
end
|
|
||||||
local note = grp.short and fg.label(b, grp.here > 0 and ("Only " .. ui.money(grp.free) .. " left after current drills")
|
|
||||||
or "Well spoken for: nothing left", { font = "sans", px = 11, color = "ready" }, 14, { fit = true, shrink = 1 }) or space(0, 14)
|
|
||||||
local count = grp.count > 1 and ("×" .. grp.count) or nil
|
|
||||||
local count_w = count and text_w(b, count, { font = "mono", px = 12, color = "text" }) or 0
|
|
||||||
local tree = col({ width = inner, height = hgt }, {
|
|
||||||
-- The picture (a square, centered; grey when spoken for) on the media strip.
|
|
||||||
made({ height = mh, shrink = 0 }, function(q, ix, iy, iw)
|
|
||||||
ui.rect(q, ix + 1, iy + 1, iw - 2, mh - 1, "bg")
|
|
||||||
local img = ui.rect(q, ix + math.floor((iw - mh) / 2), iy + 1, mh - 1, mh - 1, "none")
|
|
||||||
if tool and tool.image then
|
|
||||||
images.set(img, tool.image)
|
|
||||||
if spoken then
|
|
||||||
-- Greyed (no grayscale filter here): a neutral grey over the picture.
|
|
||||||
ui.rect(q, ix + math.floor((iw - mh) / 2), iy + 1, mh - 1, mh - 1, "off", 0.6)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if n_att > 0 then
|
|
||||||
ui.rect(q, ix + 6, iy + 6, n_att * 13 + 4, 18, "panel", 0.82)
|
|
||||||
cards.pips(q, n_att, n_att, ix + 8, iy + 15)
|
|
||||||
end
|
|
||||||
if count then
|
|
||||||
ui.rect(q, ix + iw - 6 - count_w - 10, iy + 6, count_w + 10, 20, "panel", 0.82)
|
|
||||||
ui.text(q, count, { font = "mono", px = 12, color = "text" }, ix + iw - 6 - count_w - 5, iy + 16)
|
|
||||||
end
|
|
||||||
end),
|
|
||||||
made({ height = 1, shrink = 0 }, function(q, ix, iy, iw) ui.rect(q, ix + 1, iy, iw - 2, 1, "line") end),
|
|
||||||
col({ padding = { 7, 9, 8, 9 }, gap = 3, grow = 1 }, {
|
|
||||||
row({ height = 16, align = "center" }, {
|
|
||||||
fg.label(b, grp.name, { font = "sans_semi", px = 13, color = "text" }, 16, { fit = true, shrink = 1 }),
|
|
||||||
space(6), fg.label(b, "T" .. grp.tier, { font = "mono", px = 11, color = "cyan" }, 16, { shrink = 0 }),
|
|
||||||
}),
|
|
||||||
row({ height = 16, align = "center" }, {
|
|
||||||
gets, reach and space(5) or nil, reach, fg.fill(),
|
|
||||||
space(5), fg.label(b, "Deploy", { font = "sans_semi", px = 11, color = "cyan", tracking = 0.06, upper = true }, 16, { shrink = 0 }),
|
|
||||||
}),
|
|
||||||
note,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
fg.build(flex.solve(tree, 0, 0), b)
|
|
||||||
local title = "Deploy " .. grp.name .. " T" .. grp.tier
|
|
||||||
for _, a in ipairs(grp.attachments or {}) do
|
|
||||||
title = title .. " · " .. (type(a) == "table" and a.name or tostring(a))
|
|
||||||
end
|
|
||||||
tooltip.add(b, title)
|
|
||||||
if not grp.blocked then
|
|
||||||
H.action(g, b, bn, "stake", { tool = grp.toolId, target = well.id })
|
|
||||||
end
|
|
||||||
if spoken then
|
|
||||||
local veil = ui.rect(b, 0, 0, inner, hgt, "veil", 0.4)
|
|
||||||
if not grp.blocked then
|
|
||||||
g.druid:new_hover(b, nil, function(_, on)
|
|
||||||
gui.cancel_animations(veil, "color.w")
|
|
||||||
local a = on and 0.15 or 0.4
|
|
||||||
if anim.reduced or button.instant then
|
|
||||||
anim.set(veil, "color.w", a)
|
|
||||||
else
|
|
||||||
gui.animate(veil, "color.w", a, gui.EASING_OUTQUAD, 0.15)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A drill on the well (34 tall; it slides in when the drill was just deployed):
|
-- A drill on the well (34 tall; it slides in when the drill was just deployed):
|
||||||
-- thumbnail, name, tier and the attachments' tags; what it gets; time left or Ready.
|
-- thumbnail, name and track levels; what it gets; time left or Ready.
|
||||||
local function drill_line(g, c, st, d, H, enters)
|
local function drill_line(g, c, st, d, H, enters)
|
||||||
local name, tier = moons.tool_name(st, d.toolId)
|
local name, tag = moons.tool_name(st, d.toolId)
|
||||||
local tool = find(st.tools, d.toolId)
|
local tool = find(st.tools, d.toolId)
|
||||||
return made({ height = 34 }, function(p, x, y, inner)
|
return made({ height = 34 }, function(p, x, y, inner)
|
||||||
local dr = ui.rect(p, x, y, inner, 34, "none")
|
local dr = ui.rect(p, x, y, inner, 34, "none")
|
||||||
@@ -268,29 +141,11 @@ local function drill_line(g, c, st, d, H, enters)
|
|||||||
end
|
end
|
||||||
local x0 = 36
|
local x0 = 36
|
||||||
local nt = ui.text(dr, name, CARD_NAME, x0, 8)
|
local nt = ui.text(dr, name, CARD_NAME, x0, 8)
|
||||||
local tt = ui.text(dr, "T" .. tier, M11, x0 + ui.text_width(nt) + 6, 8)
|
local head = { fg.node_label(nt, 16) }
|
||||||
-- Tags while they fit (and leave 50 for the time).
|
if tag ~= "" then
|
||||||
local head = { fg.node_label(nt, 16), space(6), fg.node_label(tt, 16), space(8) }
|
local tt = ui.text(dr, tag:sub(2), M11, x0 + ui.text_width(nt) + 6, 8)
|
||||||
local tx = x0 + ui.text_width(nt) + 6 + ui.text_width(tt) + 8
|
head[#head + 1] = space(6)
|
||||||
for _, a in ipairs(tool and tool.attachments or {}) do
|
head[#head + 1] = fg.node_label(tt, 16)
|
||||||
if tx + 60 > inner - 50 then
|
|
||||||
break
|
|
||||||
end
|
|
||||||
local probe = ui.rect(dr, 0, 0, 1, 1, "none")
|
|
||||||
local aw = cards.tag(probe, a, 0, 0)
|
|
||||||
gui.delete_node(probe)
|
|
||||||
if tx + aw > inner - 50 then
|
|
||||||
break
|
|
||||||
end
|
|
||||||
head[#head + 1] = made({ width = aw, height = 16, shrink = 0 }, function(q, ax, ay)
|
|
||||||
cards.tag(q, a, ax, ay + 8)
|
|
||||||
local fx_text = effect_of(st, a)
|
|
||||||
if fx_text then
|
|
||||||
tooltip.add(ui.rect(q, ax, ay, aw, 16, "none"), fx_text)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
head[#head + 1] = space(4)
|
|
||||||
tx = tx + aw + 4
|
|
||||||
end
|
end
|
||||||
-- The amount it gets in ready when it comes up short (engine markup runs).
|
-- The amount it gets in ready when it comes up short (engine markup runs).
|
||||||
local reach = (d.reach or 1) > 1 and (" · " .. ((d.reachText or "") ~= "" and d.reachText or reach_short(st, d.reach))) or ""
|
local reach = (d.reach or 1) > 1 and (" · " .. ((d.reachText or "") ~= "" and d.reachText or reach_short(st, d.reach))) or ""
|
||||||
@@ -335,11 +190,6 @@ local function drill_line(g, c, st, d, H, enters)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A row of claim buttons (Claim / its countdown, and Claim and redeploy beside it).
|
|
||||||
local function claim_row(kids)
|
|
||||||
return row({ height = S.button_h, gap = 8 }, kids)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The selected well under the viewport (one tree; it scrolls); returns its bottom.
|
-- The selected well under the viewport (one tree; it scrolls); returns its bottom.
|
||||||
local function well_panel(g, root, pad, top, inner, H)
|
local function well_panel(g, root, pad, top, inner, H)
|
||||||
local st = g.state
|
local st = g.state
|
||||||
@@ -376,8 +226,7 @@ local function well_panel(g, root, pad, top, inner, H)
|
|||||||
-- The well note in the server's words (the web's): the lead (over-drilled, fully
|
-- The well note in the server's words (the web's): the lead (over-drilled, fully
|
||||||
-- committed) in the warning color, then what the drills take of it, amber once
|
-- committed) in the warning color, then what the drills take of it, amber once
|
||||||
-- they want it all; one paragraph (engine markup runs). Its two lines are kept
|
-- they want it all; one paragraph (engine markup runs). Its two lines are kept
|
||||||
-- when there is no note, and "After current drills" is always there: the first
|
-- when there is no note, so a deploy doesn't make the facts jump.
|
||||||
-- deploy must not push the deploy cards down (buttons never move under the pointer).
|
|
||||||
local note = nil
|
local note = nil
|
||||||
if (wrow.noteText or "") ~= "" then
|
if (wrow.noteText or "") ~= "" then
|
||||||
local runs = {}
|
local runs = {}
|
||||||
@@ -420,54 +269,27 @@ local function well_panel(g, root, pad, top, inner, H)
|
|||||||
local wide = moon.wide
|
local wide = moon.wide
|
||||||
local wide_here = {}
|
local wide_here = {}
|
||||||
if wide and #(wrow.wide or {}) > 0 then
|
if wide and #(wrow.wide or {}) > 0 then
|
||||||
for _, d in ipairs(wide.drills or {}) do
|
-- The server's here on these is the whole moon (what it gets in all): this well's
|
||||||
if contains(wrow.wide, d.id) then
|
-- share comes from the projection (the take from this well).
|
||||||
wide_here[#wide_here + 1] = d
|
local p = predict.project(st, moon)
|
||||||
|
local wi
|
||||||
|
for i, x in ipairs(moon.wells or {}) do
|
||||||
|
if x.id == w.id then
|
||||||
|
wi = i
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
for _, d in ipairs(wide.drills or {}) do
|
||||||
|
if contains(wrow.wide, d.id) then
|
||||||
-- The web's order: deploy, drills, then the claims and Redrill.
|
local dd = {}
|
||||||
if not dry then
|
for k, v in pairs(d) do
|
||||||
add(fg.label(c, "Deploy a tool", "h3", 12))
|
dd[k] = v
|
||||||
add(space(0, 6))
|
end
|
||||||
if #w.idle > 0 then
|
local take = p.take[d.id]
|
||||||
local cards_ = {}
|
if take and wi then
|
||||||
local _, cw = flex.columns(inner, DEPLOY_MIN, DEPLOY_GAP)
|
dd.here = take[wi] or 0
|
||||||
for _, grp in ipairs(w.idle) do
|
end
|
||||||
cards_[#cards_ + 1] = deploy_card(g, c, w, grp, H, cw)
|
wide_here[#wide_here + 1] = dd
|
||||||
end
|
end
|
||||||
add(flex.grid(inner, DEPLOY_MIN, DEPLOY_GAP, cards_))
|
|
||||||
add(space(0, 6))
|
|
||||||
local better = w.better
|
|
||||||
if better then
|
|
||||||
-- "Better: <resource> well on <moon>, X free": the well is a link.
|
|
||||||
local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
|
|
||||||
local rest = (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free"
|
|
||||||
local name, tier = moons.tool_name(st, better.toolId)
|
|
||||||
add(row({ height = 40, align = "start" }, {
|
|
||||||
row({ height = S.button_sm_h, grow = 1, shrink = 1, align = "center", padding_right = 8 }, {
|
|
||||||
fg.label(c, "Better:", "small", 16), space(4),
|
|
||||||
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, better.resource) end),
|
|
||||||
space(4),
|
|
||||||
link_box(g, c, H, better.resource .. " well", "small", 16,
|
|
||||||
{ section = "moons", moon = better.moonId, well = better.wellId }, "better-well"),
|
|
||||||
fg.label(c, rest, "small", 16, { fit = not ui.needs_rich(rest), shrink = 1 }),
|
|
||||||
}),
|
|
||||||
fg.button(c, "Deploy there", { small = true, primary = true }, function(b, bn)
|
|
||||||
H.action(g, b, bn, "stake", { tool = better.toolId, target = better.wellId, s = "moons", moon = better.moonId, well = better.wellId })
|
|
||||||
tooltip.add(b, "Deploy " .. name .. " T" .. tier .. " on that well")
|
|
||||||
end),
|
|
||||||
}))
|
|
||||||
end
|
|
||||||
else
|
|
||||||
add(row({ height = 16, align = "center" }, {
|
|
||||||
fg.label(c, "No idle tools.", "small", 16),
|
|
||||||
st.unlocked.store and space(5) or nil,
|
|
||||||
st.unlocked.store and link_box(g, c, H, "Buy one in the store", { font = "sans", px = 13 }, 16, { section = "store", moon = g.view.moon }) or nil,
|
|
||||||
st.unlocked.store and fg.label(c, ".", "small", 16) or nil,
|
|
||||||
}))
|
|
||||||
add(space(0, 6))
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -499,62 +321,25 @@ local function well_panel(g, root, pad, top, inner, H)
|
|||||||
add(col({ gap = 2 }, lines))
|
add(col({ gap = 2 }, lines))
|
||||||
add(space(0, #lines > 0 and 10 or 8))
|
add(space(0, #lines > 0 and 10 or 8))
|
||||||
|
|
||||||
-- Claim (ready), Claim countdown (mining), Claim and redeploy; the multi-well claim;
|
-- Idle drills that last mined it (Redrill on its card), and a better well for the
|
||||||
-- Redrill. Amounts move on as more drills finish on our clock (core/ready).
|
-- idle drills (the server's pick), as text.
|
||||||
local claims = {}
|
if (wrow.redrill or 0) > 0 then
|
||||||
local amount = ready.amount(g, wrow, wrow.supply)
|
add(fact(H, "Idle, last mined here", tostring(wrow.redrill)))
|
||||||
if wrow.ready > 0 then
|
add(space(0, 6))
|
||||||
claims[#claims + 1] = claim_row({
|
|
||||||
fg.button(c, "Claim " .. ui.money(amount), { primary = true }, function(b, bn)
|
|
||||||
H.action(g, b, bn, "claim", { ["claim-well"] = w.id })
|
|
||||||
end),
|
|
||||||
(not dry and st.unlocked.redeploy) and fg.button(c, "Claim and redeploy", {}, function(b, bn)
|
|
||||||
H.action(g, b, bn, "claim", { ["claim-well"] = w.id, redeploy = "1" })
|
|
||||||
end) or nil,
|
|
||||||
})
|
|
||||||
elseif wrow.nextEnd then
|
|
||||||
claims[#claims + 1] = made({ height = S.button_h }, function(p, x, y)
|
|
||||||
H.claim_wait(g, p, x, y, wrow.nextEnd, { key = "wait:well", params = { ["claim-well"] = w.id }, moon = moon.id,
|
|
||||||
done = amount > 0 and ("Claim " .. ui.money(amount)) or nil,
|
|
||||||
also = not dry and st.unlocked.redeploy
|
|
||||||
and { label = "Claim and redeploy", params = { ["claim-well"] = w.id, redeploy = "1" } } or nil })
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
if #wide_here > 0 then
|
local better = w.better
|
||||||
local wamount = ready.amount(g, wide, wide.cap)
|
if better and not dry then
|
||||||
if wide.ready > 0 then
|
local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
|
||||||
claims[#claims + 1] = claim_row({
|
local name, tag = moons.tool_name(st, better.toolId)
|
||||||
fg.button(c, "Claim " .. ui.money(wamount), { primary = true }, function(b, bn)
|
add(fg.text_para(c, "Better for " .. name .. tag .. ": the " .. better.resource .. " well"
|
||||||
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id })
|
.. (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free.", "small", { max_width = math.min(inner, S.text_max) }))
|
||||||
end),
|
add(space(0, 6))
|
||||||
st.unlocked.redeploy and fg.button(c, "Claim and redeploy", {}, function(b, bn)
|
|
||||||
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id, redeploy = "1" })
|
|
||||||
end) or nil,
|
|
||||||
})
|
|
||||||
elseif wide.nextEnd then
|
|
||||||
claims[#claims + 1] = made({ height = S.button_h }, function(p, x, y)
|
|
||||||
H.claim_wait(g, p, x, y, wide.nextEnd, { params = { ["claim-wide"] = moon.id }, moon = moon.id,
|
|
||||||
done = wamount > 0 and ("Claim " .. ui.money(wamount)) or nil })
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if (wrow.redrill or 0) > 0 and not dry and st.unlocked.redrill then
|
|
||||||
claims[#claims + 1] = claim_row({
|
|
||||||
fg.button(c, "Redrill" .. (wrow.redrill > 1 and (" " .. wrow.redrill) or ""), { variant = "redrill" }, function(b, bn)
|
|
||||||
H.action(g, b, bn, "redrill", { ["redrill-well"] = w.id })
|
|
||||||
end),
|
|
||||||
})
|
|
||||||
end
|
|
||||||
add(col({ gap = 10, padding_bottom = #claims > 0 and 10 or 0 }, claims))
|
|
||||||
|
|
||||||
-- Selling the moon (once the store is open), at the foot of the moon's pane.
|
|
||||||
if st.unlocked.store and moon.sale then
|
|
||||||
add(space(0, 14))
|
|
||||||
add(made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end))
|
|
||||||
add(space(0, 12))
|
|
||||||
add(moon_sale.box(g, H, c, moon, inner))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- (no buttons here but Sell moon: every action on the well is on its card)
|
||||||
|
for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
|
||||||
|
add(k)
|
||||||
|
end
|
||||||
local tree = col({ width = inner }, kids)
|
local tree = col({ width = inner }, kids)
|
||||||
fg.build(flex.solve(tree, pad, top), c)
|
fg.build(flex.solve(tree, pad, top), c)
|
||||||
for _, f in ipairs(enters) do
|
for _, f in ipairs(enters) do
|
||||||
@@ -590,6 +375,47 @@ function M.row_viewport(w)
|
|||||||
return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2)))
|
return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2)))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The selected moon's facts under the viewport when no well is picked (read only).
|
||||||
|
local function moon_status(g, c, x, y, inner, H)
|
||||||
|
local st = g.state
|
||||||
|
local moon = st.moon
|
||||||
|
local supply, dry, mining, done = 0, 0, 0, 0
|
||||||
|
for _, w in ipairs(moon.wells or {}) do
|
||||||
|
supply = supply + w.supply
|
||||||
|
dry = dry + (w.supply <= 0 and 1 or 0)
|
||||||
|
end
|
||||||
|
local now = H.now(g)
|
||||||
|
for _, s in ipairs(st.stakes or {}) do
|
||||||
|
if s.moonId == moon.id then
|
||||||
|
if s.ready or (s["end"] or math.huge) <= now then
|
||||||
|
done = done + 1
|
||||||
|
else
|
||||||
|
mining = mining + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local idle = 0
|
||||||
|
for _, t in ipairs(st.tools or {}) do
|
||||||
|
idle = idle + (t.stakeId and 0 or 1)
|
||||||
|
end
|
||||||
|
local kids = {
|
||||||
|
fg.label(c, moon.name, "h2", 18), space(0, 10),
|
||||||
|
fg.text_para(c, "Click a well's card for its details.", "small", { max_width = math.min(inner, S.text_max) }),
|
||||||
|
space(0, 10),
|
||||||
|
fact(H, "Wells", tostring(#(moon.wells or {})) .. (dry > 0 and (" · " .. dry .. " tapped out") or "")),
|
||||||
|
fact(H, "Supply left", ui.money(supply)),
|
||||||
|
fact(H, "Drills mining", tostring(mining)),
|
||||||
|
fact(H, "Ready to claim", tostring(done), { color = done > 0 and "ready" or "text" }),
|
||||||
|
fact(H, "Idle drills", tostring(idle)),
|
||||||
|
}
|
||||||
|
for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
|
||||||
|
kids[#kids + 1] = k
|
||||||
|
end
|
||||||
|
local tree = col({ width = inner }, kids)
|
||||||
|
fg.build(flex.solve(tree, x, y), c)
|
||||||
|
return tree.y + tree.h
|
||||||
|
end
|
||||||
|
|
||||||
-- A heading (h2) and a line under the viewport.
|
-- A heading (h2) and a line under the viewport.
|
||||||
local function caption(c, x, y, inner, title, text)
|
local function caption(c, x, y, inner, title, text)
|
||||||
fg.build(flex.solve(col({ width = inner }, {
|
fg.build(flex.solve(col({ width = inner }, {
|
||||||
@@ -598,10 +424,83 @@ local function caption(c, x, y, inner, title, text)
|
|||||||
}), x, y), c)
|
}), x, y), c)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The facts under the viewport on the Moons section, built into root (at where.root's
|
||||||
|
-- origin; where: what M.build returned with the panel): the selected well's panel, the moon's status, or a mined-out caption. A
|
||||||
|
-- block of their own (core/main.lua), kept while M.facts_sig stays.
|
||||||
|
function M.facts(g, root, where, H)
|
||||||
|
local st = g.state
|
||||||
|
local moon = st.moon
|
||||||
|
local top, w, h, pad, inner = where.top, where.w, where.h, where.pad, where.inner
|
||||||
|
if st.well then
|
||||||
|
-- The well panel scrolls under the viewport (many drills).
|
||||||
|
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
|
||||||
|
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
|
||||||
|
elseif moon.finished then
|
||||||
|
caption(root, pad, where.cy, inner, moon.name, "Mined out. Nothing left to dig.")
|
||||||
|
else
|
||||||
|
-- (scrolls like the well panel: Sell moon at its foot)
|
||||||
|
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
|
||||||
|
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What M.facts shows (its block's signature, with where): from the state and our clock.
|
||||||
|
function M.facts_sig(g, where, H)
|
||||||
|
local st = g.state
|
||||||
|
local moon = st.moon
|
||||||
|
local now = H.now(g)
|
||||||
|
local sale = st.unlocked.store and moon.sale or false
|
||||||
|
local hold = sale and sale.holdEnd and sale.holdEnd > now and math.floor((sale.holdEnd - now) / 1000) or false
|
||||||
|
local common = { where.top, where.cy, where.w, where.h, where.inner, moon.id, moon.name, moon.finished or false,
|
||||||
|
sale, hold, g.sale_open and g.sale_open[moon.id] or false, g.ask and g.ask.id or false, st.catalog or false, st.catalogVersion or false }
|
||||||
|
if moon.finished then
|
||||||
|
return blocks.sig(common)
|
||||||
|
end
|
||||||
|
local supplies = {}
|
||||||
|
for i, x in ipairs(moon.wells or {}) do
|
||||||
|
supplies[i] = x.supply
|
||||||
|
end
|
||||||
|
if st.well then
|
||||||
|
local w = st.well
|
||||||
|
local wrow = find(moon.wells, w.id) or false
|
||||||
|
local tools = {}
|
||||||
|
for i, d in ipairs(wrow and wrow.drills or {}) do
|
||||||
|
local t = find(st.tools, d.toolId)
|
||||||
|
tools[i] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
|
||||||
|
end
|
||||||
|
local wide = wrow and #(wrow.wide or {}) > 0 and moon.wide or false
|
||||||
|
for _, d in ipairs(wide and wide.drills or {}) do
|
||||||
|
local t = find(st.tools, d.toolId)
|
||||||
|
tools[#tools + 1] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
|
||||||
|
end
|
||||||
|
local better = w.better or false
|
||||||
|
local bt = better and find(st.tools, better.toolId)
|
||||||
|
local bm = better and find(st.moons, better.moonId)
|
||||||
|
return blocks.sig(common, w.id, w.resource, w.rate, better, bt and { bt.name or false, ui.levels_tag(bt) } or false,
|
||||||
|
bm and bm.name or false, wrow, wide, wide and moon.wells or false, tools, st.bonuses or false, moon.maxSupply or false,
|
||||||
|
st.rules and st.rules.maxSupply or false, moons.supply_max(st, moon), anim.reduced)
|
||||||
|
end
|
||||||
|
local mining, done, idle = 0, 0, 0
|
||||||
|
for _, s in ipairs(st.stakes or {}) do
|
||||||
|
if s.moonId == moon.id then
|
||||||
|
if s.ready or (s["end"] or math.huge) <= now then
|
||||||
|
done = done + 1
|
||||||
|
else
|
||||||
|
mining = mining + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, t in ipairs(st.tools or {}) do
|
||||||
|
idle = idle + (t.stakeId and 0 or 1)
|
||||||
|
end
|
||||||
|
return blocks.sig(common, supplies, mining, done, idle)
|
||||||
|
end
|
||||||
|
|
||||||
-- Region contract (AGENTS.md, "Game screen"): builds the aside panel at (x, y), w x h.
|
-- Region contract (AGENTS.md, "Game screen"): builds the aside panel at (x, y), w x h.
|
||||||
-- mod: the current section's module (its aside / aside_below, if any). row: the narrow
|
-- mod: the current section's module (its aside / aside_below, if any). row: the narrow
|
||||||
-- layout's row (core/main.lua): the viewport on the left, the side panel beside it.
|
-- layout's row (core/main.lua): the viewport on the left, the side panel beside it.
|
||||||
-- Returns the panel (components/panel: its entrance).
|
-- Returns the panel (components/panel: its entrance) and, on the Moons section with a
|
||||||
|
-- moon, where its facts go (M.facts).
|
||||||
function M.build(g, parent, x, y, w, h, H, mod, row)
|
function M.build(g, parent, x, y, w, h, H, mod, row)
|
||||||
local st = g.state
|
local st = g.state
|
||||||
local p = panel.new(parent, x, y, w, h)
|
local p = panel.new(parent, x, y, w, h)
|
||||||
@@ -611,7 +510,13 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
|
|||||||
local section = g.view.section
|
local section = g.view.section
|
||||||
local moon = section == "moons" and st.moon or nil
|
local moon = section == "moons" and st.moon or nil
|
||||||
local cy
|
local cy
|
||||||
if row then
|
-- A section panel that wants the whole aside (the drill panel): no moon over it.
|
||||||
|
local full = mod and mod.aside_full and st[section] and mod.aside_full(g)
|
||||||
|
if full then
|
||||||
|
moon_view.hide()
|
||||||
|
g.viewport_node, g.viewport_rect = nil, nil
|
||||||
|
cy = 8
|
||||||
|
elseif row then
|
||||||
local vp = M.row_viewport(w)
|
local vp = M.row_viewport(w)
|
||||||
viewport(g, root, pad, pad, vp, shown_moon(g))
|
viewport(g, root, pad, pad, vp, shown_moon(g))
|
||||||
-- The side panel gets the rest of the row as its own box.
|
-- The side panel gets the rest of the row as its own box.
|
||||||
@@ -644,14 +549,10 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
|
|||||||
end
|
end
|
||||||
gui.delete_node(box)
|
gui.delete_node(box)
|
||||||
end
|
end
|
||||||
if moon and st.well then
|
if moon then
|
||||||
-- The well panel scrolls under the viewport (many deploy cards or drills).
|
-- The moon's facts are a block of their own (M.facts, core/main.lua): a change the
|
||||||
local top = cy - 8
|
-- viewport doesn't show keeps the panel and its moon.
|
||||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
|
return p, { root = root, top = cy - 8, cy = cy, w = w, h = h, pad = pad, inner = inner }
|
||||||
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
|
|
||||||
elseif moon then
|
|
||||||
caption(root, pad, cy, inner, moon.name, moon.finished and "Mined out. Nothing left to dig."
|
|
||||||
or "Pick a well in the table to deploy tools and claim resources.")
|
|
||||||
elseif mod and mod.aside_below and st[section] then
|
elseif mod and mod.aside_below and st[section] then
|
||||||
mod.aside_below(g, root, pad, cy, inner, limit, H)
|
mod.aside_below(g, root, pad, cy, inner, limit, H)
|
||||||
elseif section == "new-moon" then
|
elseif section == "new-moon" then
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ local STEPS = {
|
|||||||
function(a) return a.g.view.section == "new-moon" and shot("11-new-moon") end,
|
function(a) return a.g.view.section == "new-moon" and shot("11-new-moon") end,
|
||||||
function(a) return a:press("buy-moon") end,
|
function(a) return a:press("buy-moon") end,
|
||||||
function(a) return a.g.reveal:card_done() and shot("12-reveal") end,
|
function(a) return a.g.reveal:card_done() and shot("12-reveal") end,
|
||||||
-- Buy another, straight from the card (its button registers as buy-moon).
|
-- Buy another, straight from the card (its button registers as reveal-buy).
|
||||||
function(a) return a:press("buy-moon") end,
|
function(a) return a:press("reveal-buy") end,
|
||||||
function(a) return #a.g.state.moons > 2 and a.g.reveal:card_done() and shot("12-reveal-another") end,
|
function(a) return #a.g.state.moons > 2 and a.g.reveal:card_done() and shot("12-reveal-another") end,
|
||||||
function(a) return a:press("reveal-close") end,
|
function(a) return a:press("reveal-close") end,
|
||||||
function(a) return not a.g.reveal:active() and #a.g.state.moons > 1 and shot("12-second-moon") end,
|
function(a) return not a.g.reveal:active() and #a.g.state.moons > 1 and shot("12-second-moon") end,
|
||||||
|
|||||||
@@ -109,13 +109,9 @@ local STEPS = {
|
|||||||
function(a) return a.g.view.section == "market" and shot("10-market-leaderboard") end,
|
function(a) return a.g.view.section == "market" and shot("10-market-leaderboard") end,
|
||||||
function(a) return go(a, "help") end,
|
function(a) return go(a, "help") end,
|
||||||
function(a) return a.g.view.section == "help" and shot("11-help") end,
|
function(a) return a.g.view.section == "help" and shot("11-help") end,
|
||||||
-- Start over: arm the button (its first click), then run new-game.
|
-- Start over: open its confirm, then run new-game (as its OK).
|
||||||
function(a)
|
function(a) return a:press("confirm:new-game") end,
|
||||||
a.g.help_armed = a.g.h.now(a.g) + 30000
|
function(a) return a.g.dialog.root and shot("12-help-confirm") end,
|
||||||
a.g.dirty = true
|
|
||||||
return true
|
|
||||||
end,
|
|
||||||
function() return shot("12-help-confirm") end,
|
|
||||||
function(a) return a:press("new-game") end,
|
function(a) return a:press("new-game") end,
|
||||||
function(a) return a.g.state.view.section ~= "help" or true end,
|
function(a) return a.g.state.view.section ~= "help" or true end,
|
||||||
function() return shot("13-new-game") end,
|
function() return shot("13-new-game") end,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ local button = require("components.button")
|
|||||||
local chip = require("components.chip")
|
local chip = require("components.chip")
|
||||||
local stat = require("components.stat")
|
local stat = require("components.stat")
|
||||||
local fg = require("modules.flex_gui")
|
local fg = require("modules.flex_gui")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
|
|
||||||
@@ -31,11 +32,12 @@ function M.compact(n)
|
|||||||
local function one(f)
|
local function one(f)
|
||||||
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
|
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
|
||||||
end
|
end
|
||||||
if a >= 1e6 then
|
-- The unit is picked after rounding (999500 is 1M, not 1000k; 999.6 is 1k).
|
||||||
|
if a >= 1e6 - 500 then
|
||||||
return one(n / 1e6) .. "M"
|
return one(n / 1e6) .. "M"
|
||||||
elseif a >= 1e4 then
|
elseif a >= 1e4 then
|
||||||
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k"
|
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k"
|
||||||
elseif a >= 1e3 then
|
elseif a >= 1e3 - 0.5 then
|
||||||
return one(n / 1e3) .. "k"
|
return one(n / 1e3) .. "k"
|
||||||
end
|
end
|
||||||
return ui.money(math.floor(n + 0.5))
|
return ui.money(math.floor(n + 0.5))
|
||||||
@@ -48,14 +50,13 @@ function M.pct(m)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local EFFECT_WORDS = {
|
local EFFECT_WORDS = {
|
||||||
attachment = { { "speed", "speed" }, { "yield", "yield" } },
|
|
||||||
advisor = { { "speed", "drill speed" }, { "yield", "yield" }, { "sell", "sell prices" }, { "refine", "refinery speed" } },
|
advisor = { { "speed", "drill speed" }, { "yield", "yield" }, { "sell", "sell prices" }, { "refine", "refinery speed" } },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- "+20% speed", "+5% drill speed, +5% yield" (kind: "attachment" or "advisor").
|
-- "+5% drill speed, +5% yield" (kind: "advisor").
|
||||||
function M.effect(effect, kind)
|
function M.effect(effect, kind)
|
||||||
local parts = {}
|
local parts = {}
|
||||||
for _, e in ipairs(EFFECT_WORDS[kind or "attachment"]) do
|
for _, e in ipairs(EFFECT_WORDS[kind or "advisor"]) do
|
||||||
local m = effect and effect[e[1]] or 1
|
local m = effect and effect[e[1]] or 1
|
||||||
if m > 1 then
|
if m > 1 then
|
||||||
parts[#parts + 1] = M.pct(m) .. " " .. e[2]
|
parts[#parts + 1] = M.pct(m) .. " " .. e[2]
|
||||||
@@ -187,29 +188,12 @@ function M.stat_up(parent, label, from, to, x, y)
|
|||||||
return w
|
return w
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A violet tag (attachment names); returns its width.
|
-- A violet tag (advisor labels); returns its width and the box.
|
||||||
function M.tag(parent, text, x, y, color)
|
function M.tag(parent, text, x, y, color)
|
||||||
local box, w = chip.new(parent, text, x, y, { color = color or "violet", alpha = 0.5, text = color or "violet_hi", upper = true })
|
local box, w = chip.new(parent, text, x, y, { color = color or "violet", alpha = 0.5, text = color or "violet_hi", upper = true })
|
||||||
return w, box
|
return w, box
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Attachment slot pips: filled diamonds for used slots. Returns the width.
|
|
||||||
function M.pips(parent, used, slots, x, y)
|
|
||||||
for i = 1, slots do
|
|
||||||
local p = ui.rect(parent, x + (i - 1) * 13 + 5, y, 8, 8, "violet", i <= used and 1 or 0)
|
|
||||||
gui.set_pivot(p, gui.PIVOT_CENTER)
|
|
||||||
gui.set_position(p, vec(x + (i - 1) * 13 + 5, -y))
|
|
||||||
gui.set_rotation(p, vmath.vector3(0, 0, 45))
|
|
||||||
if i > used then
|
|
||||||
local o = ui.outline(parent, 0, 0, 8, 8, "violet")
|
|
||||||
gui.set_pivot(o, gui.PIVOT_CENTER)
|
|
||||||
gui.set_position(o, vec(x + (i - 1) * 13 + 5, -y))
|
|
||||||
gui.set_rotation(o, vmath.vector3(0, 0, 45))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return slots * 13
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Refined parts an upgrade needs: "● 3/5 Dusk Alloy" chips, red when short. Returns the
|
-- Refined parts an upgrade needs: "● 3/5 Dusk Alloy" chips, red when short. Returns the
|
||||||
-- height used.
|
-- height used.
|
||||||
function M.parts(st, parent, parts, x, y, w)
|
function M.parts(st, parent, parts, x, y, w)
|
||||||
@@ -278,11 +262,35 @@ function M.confirm_server_button(self, b, c, act, params, id)
|
|||||||
end
|
end
|
||||||
end })
|
end })
|
||||||
self.fx:button(id, b)
|
self.fx:button(id, b)
|
||||||
|
-- Autoplay presses `act` as if confirmed: it runs the action (through the dialog when
|
||||||
|
-- its confirm is open), and presses `id` to open the confirm.
|
||||||
|
if self.autoplay then
|
||||||
|
local function confirmed()
|
||||||
|
local d = self.dialog
|
||||||
|
if d.root and not d.closing and d.opts and d.opts.ok and d.opts.ok.act == act then
|
||||||
|
d:act(act, params)
|
||||||
|
else
|
||||||
|
self.fx:origin(b, id)
|
||||||
|
self.autoplay.fns.act(self, act, params or {}) -- (session.act)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function ask()
|
||||||
|
M.confirm_server(self, c, act, params, opener(self, b, id))
|
||||||
|
end
|
||||||
|
local function register()
|
||||||
|
if self.autoplay then
|
||||||
|
self.autoplay:register(act, params, confirmed)
|
||||||
|
self.autoplay:register(id, params, ask)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
register()
|
||||||
|
blocks.note(register) -- (a kept block registers again)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---------------------------------------------------------------- inline confirm
|
---------------------------------------------------------------- inline confirm
|
||||||
|
|
||||||
-- Sells ask in place, not in a dialog (sell-tool, sell-attachment, sell-moon): the first
|
-- Sells ask in place, not in a dialog (sell-tool, sell-moon): the first
|
||||||
-- click on the button (M.ask_button) puts it in its asking state, which its section builds
|
-- click on the button (M.ask_button) puts it in its asking state, which its section builds
|
||||||
-- instead of the button in the same footprint (M.asking, M.ask_buttons, M.ask_text): the
|
-- instead of the button in the same footprint (M.asking, M.ask_buttons, M.ask_text): the
|
||||||
-- button's spot becomes Cancel, Confirm (the server's OK words, "Sell · ₥750") sits just
|
-- button's spot becomes Cancel, Confirm (the server's OK words, "Sell · ₥750") sits just
|
||||||
|
|||||||
@@ -168,10 +168,10 @@ function M.chart(parent, x, y, w, h, data, opts)
|
|||||||
local tip_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
|
local tip_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
|
||||||
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
|
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
|
||||||
gui.set_enabled(cross, false)
|
gui.set_enabled(cross, false)
|
||||||
local rect
|
|
||||||
local shown = -1
|
local shown = -1
|
||||||
local function move(action)
|
local function move(action)
|
||||||
rect = rect or screen_rect(box, cw, ch)
|
-- Measured on every move: scrolling or the entrance slide moves the box.
|
||||||
|
local rect = screen_rect(box, cw, ch)
|
||||||
local inside = action.screen_x >= rect.x0 and action.screen_x <= rect.x1
|
local inside = action.screen_x >= rect.x0 and action.screen_x <= rect.x1
|
||||||
and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
|
and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
|
||||||
if not inside then
|
if not inside then
|
||||||
|
|||||||
@@ -113,9 +113,11 @@ end
|
|||||||
-- spinner and ignores clicks until the server answers.
|
-- spinner and ignores clicks until the server answers.
|
||||||
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. hit: a node to
|
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. hit: a node to
|
||||||
-- take the clicks instead of b (a button whose visuals glide while its spot stays put).
|
-- take the clicks instead of b (a button whose visuals glide while its spot stays put).
|
||||||
|
-- With params a function, pass id ("act:<action>?<its fixed params>"): it is also the
|
||||||
|
-- busy key, so each such button has its own (else they all share the action's name).
|
||||||
-- Returns the click function (a text field's Enter runs it too).
|
-- Returns the click function (a text field's Enter runs it too).
|
||||||
function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
|
function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
|
||||||
local key = button_key(name, params)
|
local key = type(params) == "function" and id and (id:gsub("^act:", "")) or button_key(name, params)
|
||||||
if g.inflight[key] then
|
if g.inflight[key] then
|
||||||
button.set_busy(b)
|
button.set_busy(b)
|
||||||
end
|
end
|
||||||
@@ -144,9 +146,9 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
|
|||||||
if g.autoplay then
|
if g.autoplay then
|
||||||
local p = type(params) == "function" and params() or params
|
local p = type(params) == "function" and params() or params
|
||||||
g.autoplay:register(name, p, fire)
|
g.autoplay:register(name, p, fire)
|
||||||
blocks.note(function() -- (a kept block registers again)
|
blocks.note(function() -- (a kept block registers again; params made now)
|
||||||
if g.autoplay then
|
if g.autoplay then
|
||||||
g.autoplay:register(name, p, fire)
|
g.autoplay:register(name, type(params) == "function" and params() or params, fire)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ local moon_view = require("modules.moon.moon")
|
|||||||
local blocks = require("modules.blocks")
|
local blocks = require("modules.blocks")
|
||||||
local sfx = require("modules.sfx")
|
local sfx = require("modules.sfx")
|
||||||
local ready = require("screens.game.core.ready")
|
local ready = require("screens.game.core.ready")
|
||||||
|
local admin = require("screens.game.admin")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css)
|
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css)
|
||||||
@@ -87,14 +88,16 @@ end
|
|||||||
|
|
||||||
---------------------------------------------------------------- build
|
---------------------------------------------------------------- build
|
||||||
|
|
||||||
-- Entrances (enter.js): the main panel when the section or moon changes, the aside when
|
-- Entrances (enter.js): the main panel when the section (or on Moons the moon) changes, the aside when
|
||||||
-- the well changes (or the drill: the client's drill panel is in the aside); nothing else
|
-- the well changes (or the drill: the client's drill panel is in the aside); nothing else
|
||||||
-- in the view (pick, list, page, adv) makes an entrance. Links keep the moon like the
|
-- in the view (pick, list, page, adv) makes an entrance. Links keep the moon like the
|
||||||
-- web's (View.ToSection), else the server's first-moon default would change it. Both on
|
-- web's (View.ToSection), else the server's first-moon default would change it. Both on
|
||||||
-- the first state and after the reveal closes (g.enter_all).
|
-- the first state and after the reveal closes (g.enter_all).
|
||||||
function enter(g)
|
function enter(g)
|
||||||
local v = g.view
|
local v = g.view
|
||||||
local main_key = v.section .. "|" .. (v.moon or "")
|
-- (the moon only for the Moons section: elsewhere an action that moves the view's moon,
|
||||||
|
-- Claim all finishing one, mustn't replay the section's entrance)
|
||||||
|
local main_key = v.section .. "|" .. (v.section == "moons" and (v.moon or "") or "")
|
||||||
local well_key = (v.moon or "") .. "|" .. (v.well or "") .. "|" .. (v.drill or "") -- (the drill panel is in the aside)
|
local well_key = (v.moon or "") .. "|" .. (v.well or "") .. "|" .. (v.drill or "") -- (the drill panel is in the aside)
|
||||||
local all = g.enter_all or not g.entered
|
local all = g.enter_all or not g.entered
|
||||||
if g.reveal:hiding() then
|
if g.reveal:hiding() then
|
||||||
@@ -251,7 +254,7 @@ local function build_main(g, parent, x, y, w, h)
|
|||||||
local st = g.state
|
local st = g.state
|
||||||
local section = g.view.section
|
local section = g.view.section
|
||||||
local common = local_sig(g, MAIN_READY[section] or {})
|
local common = local_sig(g, MAIN_READY[section] or {})
|
||||||
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, g.side_w, g.view, g.advisor_pick, g.refine_qty, g.sale_open, g.nm_grade,
|
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, g.side_w, g.view, g.advisor_pick, g.refine_qty, g.sale_open, g.nm_grade, g.deploy_pick or false,
|
||||||
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}))
|
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}))
|
||||||
blocks.keep(g, "main", sig, parent, function(holder)
|
blocks.keep(g, "main", sig, parent, function(holder)
|
||||||
for _, m in pairs(M.SECTIONS) do
|
for _, m in pairs(M.SECTIONS) do
|
||||||
@@ -280,14 +283,25 @@ local function build_main(g, parent, x, y, w, h)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- The aside's block (narrow: the row under the panels).
|
-- The aside's block (narrow: the row under the panels).
|
||||||
local function build_aside(g, parent, x, y, w, h, row, common)
|
local function build_aside(g, parent, x, y, w, h, row, common, still)
|
||||||
local st = g.state
|
local st = g.state
|
||||||
local section = g.view.section
|
local section = g.view.section
|
||||||
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
|
-- (on Moons with a moon the panel only shows the viewport: its facts are a block of
|
||||||
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
|
-- their own, below)
|
||||||
return blocks.keep(g, "aside", sig, parent, function(holder)
|
local moon_only = section == "moons" and st.moon ~= nil
|
||||||
|
local sig = blocks.sig(moon_only and still or common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
|
||||||
|
moon_only and blocks.sig(st.moon.id, st.view and st.view.aside, st.catalogVersion or st.catalog or false)
|
||||||
|
or pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
|
||||||
|
local p, facts = blocks.keep(g, "aside", sig, parent, function(holder)
|
||||||
return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
|
return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
|
||||||
end)
|
end)
|
||||||
|
if facts then
|
||||||
|
-- The moon's facts: a block of their own inside the panel (aside.facts).
|
||||||
|
blocks.keep(g, "aside:facts", aside.facts_sig(g, facts, g.h), facts.root, function(holder)
|
||||||
|
aside.facts(g, holder, facts, g.h)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
return p
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A full rebuild drops every node and component and builds them all again; otherwise the
|
-- A full rebuild drops every node and component and builds them all again; otherwise the
|
||||||
@@ -357,11 +371,15 @@ function M.build(g)
|
|||||||
g.layout_state = true
|
g.layout_state = true
|
||||||
local st = g.state
|
local st = g.state
|
||||||
local common = local_sig(g)
|
local common = local_sig(g)
|
||||||
|
-- (without the drills and batches that ended on our clock: the banner, header and moon
|
||||||
|
-- list show those counts through their own signatures, and a drill ending mustn't
|
||||||
|
-- rebuild them)
|
||||||
|
local still = local_sig(g, {})
|
||||||
-- Announcements and live events above the header.
|
-- Announcements and live events above the header.
|
||||||
local banner_h = blocks.keep(g, "banner", blocks.sig(common, W, st.banner), g.layout, function(holder)
|
local banner_h = blocks.keep(g, "banner", blocks.sig(still, W, st.banner), g.layout, function(holder)
|
||||||
return banner.build(g, g.h, holder, W)
|
return banner.build(g, g.h, holder, W)
|
||||||
end)
|
end)
|
||||||
local header_h = blocks.keep(g, "header", blocks.sig(common, W, banner_h, g.view.section, st.credits, st.resources,
|
local header_h = blocks.keep(g, "header", blocks.sig(still, W, banner_h, g.view.section, st.credits, st.resources,
|
||||||
st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
|
st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
|
||||||
g.tab_nodes = {}
|
g.tab_nodes = {}
|
||||||
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
|
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
|
||||||
@@ -372,6 +390,25 @@ function M.build(g)
|
|||||||
local pad, gap = S.body_pad, S.gap
|
local pad, gap = S.body_pad, S.gap
|
||||||
local top = header_h + pad
|
local top = header_h + pad
|
||||||
local body_h = H - top - pad
|
local body_h = H - top - pad
|
||||||
|
if g.admin then
|
||||||
|
-- The admin area (admin.lua, the gear menu) instead of the moon list, the section and
|
||||||
|
-- the aside; kept while its own inputs stay (a state update doesn't touch it).
|
||||||
|
g.toasts:detach()
|
||||||
|
g.moon_nodes, g.well_nodes = {}, {}
|
||||||
|
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
||||||
|
g.page_key, g.page_main, g.side_w = nil, nil, 0
|
||||||
|
blocks.keep(g, "admin", blocks.sig(W, H, top, theme.name, anim.reduced, admin.sig(g)), g.layout, function(holder)
|
||||||
|
admin.build(g, holder, pad, top, W - pad * 2, body_h)
|
||||||
|
end)
|
||||||
|
blocks.finish(g)
|
||||||
|
g.tween:finish()
|
||||||
|
ticks.run(g)
|
||||||
|
g.dirty = false
|
||||||
|
g.fx:after_build(g)
|
||||||
|
g.reveal:after_build()
|
||||||
|
g.rehover = true
|
||||||
|
return
|
||||||
|
end
|
||||||
local has_aside = M.aside_shown(g)
|
local has_aside = M.aside_shown(g)
|
||||||
-- Narrow windows (the web's @media max-width 1250px): the aside goes to its own row
|
-- Narrow windows (the web's @media max-width 1250px): the aside goes to its own row
|
||||||
-- under the moon list and the main panel, the viewport and the side panel side by
|
-- under the moon list and the main panel, the viewport and the side panel side by
|
||||||
@@ -416,8 +453,8 @@ function M.build(g)
|
|||||||
for _, m in ipairs(st.moons) do
|
for _, m in ipairs(st.moons) do
|
||||||
hides[#hides + 1] = g.reveal:hides(m.id) or false
|
hides[#hides + 1] = g.reveal:hides(m.id) or false
|
||||||
end
|
end
|
||||||
blocks.keep(g, "moonlist", blocks.sig(common, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
|
blocks.keep(g, "moonlist", blocks.sig(still, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
|
||||||
st.moons, st.past, st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
|
moonlist.rows_sig(g), st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
|
||||||
st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder)
|
st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder)
|
||||||
g.moon_nodes = {}
|
g.moon_nodes = {}
|
||||||
moonlist.teardown(g)
|
moonlist.teardown(g)
|
||||||
@@ -440,16 +477,17 @@ function M.build(g)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if page then
|
if page then
|
||||||
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common)
|
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common, still)
|
||||||
local s = page:finish(y0 + body_h + gap + row_h + pad)
|
local s = page:finish(y0 + body_h + gap + row_h + pad)
|
||||||
if g.viewport_node and not g.reveal:active() then
|
if g.viewport_node and not g.reveal:active() then
|
||||||
moon_view.move(g.viewport_node) -- (where the page's offset put it)
|
moon_view.move(g.viewport_node) -- (where the page's offset put it)
|
||||||
g.viewport_rect = g.fx:rect_of(g.viewport_node)
|
g.viewport_rect = g.fx:rect_of(g.viewport_node)
|
||||||
end
|
end
|
||||||
-- Picking a well, a drill or an advisor brings the aside into view.
|
-- Picking a drill or an advisor brings the aside into view (not a well: its card,
|
||||||
|
-- where its actions are, stays in view; the aside only shows its facts).
|
||||||
local v = g.view
|
local v = g.view
|
||||||
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick) }, "|")
|
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick) }, "|")
|
||||||
local want = (v.well or "") ~= "" or (v.drill or "") ~= "" or v.section == "advisors"
|
local want = (v.drill or "") ~= "" or v.section == "advisors"
|
||||||
if s and want and key ~= g.page_key then
|
if s and want and key ~= g.page_key then
|
||||||
s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0))
|
s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0))
|
||||||
end
|
end
|
||||||
@@ -464,7 +502,7 @@ function M.build(g)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
elseif has_aside then
|
elseif has_aside then
|
||||||
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common)
|
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common, still)
|
||||||
else
|
else
|
||||||
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
||||||
if not g.reveal:active() then -- (the reveal's window shows the moon)
|
if not g.reveal:active() then -- (the reveal's window shows the moon)
|
||||||
@@ -647,6 +685,8 @@ function M.update(g, dt)
|
|||||||
g.fx:update(dt)
|
g.fx:update(dt)
|
||||||
g.reveal:update(dt)
|
g.reveal:update(dt)
|
||||||
g.dialog:update(dt)
|
g.dialog:update(dt)
|
||||||
|
-- (moon.script takes input before the GUI: a menu over the viewport must get its clicks)
|
||||||
|
moon_view.covered = g.dialog:is_open()
|
||||||
cards.ask_update(g)
|
cards.ask_update(g)
|
||||||
check_input(g)
|
check_input(g)
|
||||||
tooltip.update(g.dialog:is_open())
|
tooltip.update(g.dialog:is_open())
|
||||||
@@ -656,6 +696,9 @@ function M.update(g, dt)
|
|||||||
m.update(g, dt)
|
m.update(g, dt)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
if g.admin then
|
||||||
|
admin.update(g, dt)
|
||||||
|
end
|
||||||
g.druid:update(dt)
|
g.druid:update(dt)
|
||||||
if g.rehover then
|
if g.rehover then
|
||||||
-- After a rebuild: what is under the mouse shows its hover at once (the new
|
-- After a rebuild: what is under the mouse shows its hover at once (the new
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ local predict = require("modules.predict")
|
|||||||
local sfx = require("modules.sfx")
|
local sfx = require("modules.sfx")
|
||||||
local refinery = require("screens.game.refinery")
|
local refinery = require("screens.game.refinery")
|
||||||
local sidepanel = require("screens.game.sidepanel")
|
local sidepanel = require("screens.game.sidepanel")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -128,6 +129,9 @@ local function suspend(g)
|
|||||||
g.refetch = nil
|
g.refetch = nil
|
||||||
end
|
end
|
||||||
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {}
|
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {}
|
||||||
|
if g.history then
|
||||||
|
g.history.want = nil -- (its request was in the outbox)
|
||||||
|
end
|
||||||
g.boot = { text = SUSPENDED }
|
g.boot = { text = SUSPENDED }
|
||||||
if g.dialog and g.dialog:is_open() then
|
if g.dialog and g.dialog:is_open() then
|
||||||
g.dialog:close("cancel")
|
g.dialog:close("cancel")
|
||||||
@@ -187,7 +191,8 @@ end
|
|||||||
|
|
||||||
-- What we show: the last server state (g.base) with the pending predictions on top.
|
-- What we show: the last server state (g.base) with the pending predictions on top.
|
||||||
local function show(g)
|
local function show(g)
|
||||||
g.state = predict.apply(g.base, g.pending)
|
-- (frozen: what is shown never changes, so blocks.sig writes each part once per state)
|
||||||
|
g.state = blocks.freeze(predict.apply(g.base, g.pending))
|
||||||
g.view = g.state.view
|
g.view = g.state.view
|
||||||
arm_refetch(g)
|
arm_refetch(g)
|
||||||
g.dirty = true
|
g.dirty = true
|
||||||
@@ -215,7 +220,9 @@ end
|
|||||||
|
|
||||||
-- The views shown, like the web's browser history (main.js syncURL: every response whose
|
-- The views shown, like the web's browser history (main.js syncURL: every response whose
|
||||||
-- view differs from the current entry pushes one; back and forward fetch an entry again
|
-- view differs from the current entry pushes one; back and forward fetch an entry again
|
||||||
-- without pushing). g.history = { list = { view, ... }, i = current }.
|
-- without pushing). g.history = { list = { view, ... }, i = current, want = the index a
|
||||||
|
-- back/forward on its way goes to }. i moves when that answer comes; until then answers
|
||||||
|
-- to requests sent before it (an action, a refetch) push nothing.
|
||||||
local VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
|
local VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
|
||||||
|
|
||||||
local function view_key(v)
|
local function view_key(v)
|
||||||
@@ -242,7 +249,14 @@ local function remember(g, from_history)
|
|||||||
if from_history then
|
if from_history then
|
||||||
if h.list[from_history] then
|
if h.list[from_history] then
|
||||||
h.list[from_history] = v
|
h.list[from_history] = v
|
||||||
|
h.i = from_history
|
||||||
end
|
end
|
||||||
|
if h.want == from_history then
|
||||||
|
h.want = nil
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if h.want then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then
|
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then
|
||||||
@@ -290,6 +304,10 @@ pump = function(g)
|
|||||||
elseif g.base then
|
elseif g.base then
|
||||||
show(g) -- rolled back
|
show(g) -- rolled back
|
||||||
end
|
end
|
||||||
|
-- (a back/forward that failed: the history stays where it was)
|
||||||
|
if not applied and r.nav and r.nav.from_history and history(g).want == r.nav.from_history then
|
||||||
|
history(g).want = nil
|
||||||
|
end
|
||||||
if status == 403 then
|
if status == 403 then
|
||||||
suspend(g)
|
suspend(g)
|
||||||
g.inflight, g.busy, g.nav_view = {}, false, nil
|
g.inflight, g.busy, g.nav_view = {}, false, nil
|
||||||
@@ -404,6 +422,9 @@ function M.navigate(g, v)
|
|||||||
end
|
end
|
||||||
g.nav_view = v
|
g.nav_view = v
|
||||||
g.ask = nil -- (a sell asking in place: cards.ask_button)
|
g.ask = nil -- (a sell asking in place: cards.ask_button)
|
||||||
|
if g.admin then
|
||||||
|
require("screens.game.admin").close(g) -- (any navigation leaves the admin area)
|
||||||
|
end
|
||||||
M.fetch(g, v, v)
|
M.fetch(g, v, v)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -411,12 +432,13 @@ end
|
|||||||
-- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
|
-- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
|
||||||
function M.go(g, dir)
|
function M.go(g, dir)
|
||||||
local h = history(g)
|
local h = history(g)
|
||||||
local v = h.list[h.i + dir]
|
local i = (h.want or h.i) + dir
|
||||||
|
local v = h.list[i]
|
||||||
if not v then
|
if not v then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
h.i = h.i + dir
|
h.want = i
|
||||||
local to = { from_history = h.i }
|
local to = { from_history = i }
|
||||||
for k, x in pairs(v) do
|
for k, x in pairs(v) do
|
||||||
to[k] = x
|
to[k] = x
|
||||||
end
|
end
|
||||||
@@ -440,7 +462,7 @@ function M.act(g, name, params, on_done, fn)
|
|||||||
if predict.run(st, e) then
|
if predict.run(st, e) then
|
||||||
g.pending[#g.pending + 1] = e
|
g.pending[#g.pending + 1] = e
|
||||||
local prev = g.state
|
local prev = g.state
|
||||||
g.state = st
|
g.state = blocks.freeze(st)
|
||||||
g.dirty = true
|
g.dirty = true
|
||||||
predicted = true
|
predicted = true
|
||||||
-- The action's effects play now, from the predicted change (fx.lua).
|
-- The action's effects play now, from the predicted change (fx.lua).
|
||||||
|
|||||||
+544
-336
File diff suppressed because it is too large.
Load diff
@@ -627,7 +627,7 @@ local function build_market(self, root, px, py, w, h, H, stacked, full_w)
|
|||||||
if not empty then
|
if not empty then
|
||||||
sell_now = H.action(self, b1, n1, "sell", function()
|
sell_now = H.action(self, b1, n1, "sell", function()
|
||||||
return { resource = q.item, amount = f.text, pick = q.item }
|
return { resource = q.item, amount = f.text, pick = q.item }
|
||||||
end, clear)
|
end, clear, nil, "act:sell?pick=" .. q.item .. "&resource=" .. q.item)
|
||||||
end
|
end
|
||||||
end, { width = half }),
|
end, { width = half }),
|
||||||
fg.button(c, "Sell all", { disabled = empty }, function(b2, n2)
|
fg.button(c, "Sell all", { disabled = empty }, function(b2, n2)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Selling a moon (state.moon.sale, the web's "moon-sale" panel; here at the foot of the
|
-- Selling a moon (state.moon.sale, the web's "moon-sale" panel; here at the foot of the
|
||||||
-- aside's well panel, aside.lua):
|
-- Moons section's main panel, moons.lua):
|
||||||
-- the offer and how it compares with a new moon, the Sell moon button (asks first, in
|
-- the offer and how it compares with a new moon, the Sell moon button (asks first, in
|
||||||
-- place with the server's question: cards.ask_button; why it can't be sold yet is its tooltip), and on demand the
|
-- place with the server's question: cards.ask_button; why it can't be sold yet is its tooltip), and on demand the
|
||||||
-- note (with the hold countdown, "Sellable in") and the lines: what's left in each well at
|
-- note (with the hold countdown, "Sellable in") and the lines: what's left in each well at
|
||||||
@@ -21,7 +21,7 @@ end
|
|||||||
|
|
||||||
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||||
|
|
||||||
-- The panel, w wide: a tree piece (the aside puts it under the selected well).
|
-- The panel, w wide: a tree piece (the aside puts it at the foot of the moon's facts).
|
||||||
function M.box(self, H, c, moon, w)
|
function M.box(self, H, c, moon, w)
|
||||||
local sale = moon.sale
|
local sale = moon.sale
|
||||||
self.sale_open = self.sale_open or {}
|
self.sale_open = self.sale_open or {}
|
||||||
@@ -30,7 +30,12 @@ function M.box(self, H, c, moon, w)
|
|||||||
|
|
||||||
-- Summary: toggle (the row left of the button), offer, delta; Sell moon on the right.
|
-- Summary: toggle (the row left of the button), offer, delta; Sell moon on the right.
|
||||||
local profit = sale.delta > 0
|
local profit = sale.delta > 0
|
||||||
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta)) .. (profit and " profit" or " vs a new moon")
|
-- (narrow, in the aside: no "· offer", and the delta without its words; its tooltip has them)
|
||||||
|
local narrow = w < 460
|
||||||
|
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta))
|
||||||
|
.. (narrow and "" or (profit and " profit" or " vs a new moon"))
|
||||||
|
-- Sell moon off: why, on a line under the summary (not only in the button's tooltip).
|
||||||
|
local why = (not sale.canSell) and (sale.whyText or "") ~= "" and sale.whyText or nil
|
||||||
-- Sell moon asks in place (cards.ask_button): the question where the offer was, Confirm
|
-- Sell moon asks in place (cards.ask_button): the question where the offer was, Confirm
|
||||||
-- left of the button's spot, Cancel on it (the row's toggle is off meanwhile).
|
-- left of the button's spot, Cancel on it (the row's toggle is off meanwhile).
|
||||||
local asking = sale.canSell and cards.asking(self, "sell-moon", sale.confirm, { ["sell-moon"] = moon.id })
|
local asking = sale.canSell and cards.asking(self, "sell-moon", sale.confirm, { ["sell-moon"] = moon.id })
|
||||||
@@ -60,11 +65,24 @@ function M.box(self, H, c, moon, w)
|
|||||||
gui.set_euler(chev, vmath.vector3(0, 0, -90))
|
gui.set_euler(chev, vmath.vector3(0, 0, -90))
|
||||||
end
|
end
|
||||||
end),
|
end),
|
||||||
row({ height = 48, padding = { 0, 140, 0, 30 }, align = "center" }, {
|
row({ height = 48, padding = { 0, 156, 0, 30 }, align = "center" }, {
|
||||||
fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16), space(8),
|
-- (the label and the offer keep their size: the delta gives way, cut to fit)
|
||||||
fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16), space(8),
|
(function()
|
||||||
inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end), space(10),
|
local l = fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16)
|
||||||
fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 }),
|
l.style.shrink = 0
|
||||||
|
return l
|
||||||
|
end)(), space(10),
|
||||||
|
(not narrow) and fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16) or nil, (not narrow) and space(8) or nil,
|
||||||
|
(function()
|
||||||
|
local b = inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end)
|
||||||
|
b.style.shrink = 0
|
||||||
|
return b
|
||||||
|
end)(), space(10),
|
||||||
|
(function()
|
||||||
|
local l = fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 })
|
||||||
|
l.done = function(n) tooltip.add(n, profit and "Profit over what it cost" or "Less than a new moon costs") end
|
||||||
|
return l
|
||||||
|
end)(),
|
||||||
}),
|
}),
|
||||||
fg.button(c, "Sell moon", sell_opts, function(b)
|
fg.button(c, "Sell moon", sell_opts, function(b)
|
||||||
if not sale.canSell and (sale.whyText or "") ~= "" then
|
if not sale.canSell and (sale.whyText or "") ~= "" then
|
||||||
@@ -161,7 +179,12 @@ function M.box(self, H, c, moon, w)
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
return col({ width = w, padding_bottom = 4 }, { summary, details }, { make = function(p, x, y, bw, bh)
|
local why_line = (why and not asking) and col({ padding = { 0, 14, 10, 30 } }, {
|
||||||
|
fg.measured(c, function(p, x, y, bw)
|
||||||
|
return cards.para(p, why, { font = "sans", px = 12, color = "ready" }, x, y, bw)
|
||||||
|
end),
|
||||||
|
}) or nil
|
||||||
|
return col({ width = w, padding_bottom = 4 }, { summary, why_line, details }, { make = function(p, x, y, bw, bh)
|
||||||
local b = ui.rect(p, x, y, bw, bh, "cyan", 0.03)
|
local b = ui.rect(p, x, y, bw, bh, "cyan", 0.03)
|
||||||
ui.outline(b, 0, 0, bw, bh, "edge")
|
ui.outline(b, 0, 0, bw, bh, "edge")
|
||||||
self.fx:box(b) -- (the sale's sweep covers the panel)
|
self.fx:box(b) -- (the sale's sweep covers the panel)
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
-- Moon lab (admin area, screens/game/admin.lua): tune the procedural moon settings live
|
||||||
|
-- and save them for the game. Replaces the web's /utils/moons (moons-admin.js): the same
|
||||||
|
-- settings, groups, ranges and saved JSON (POST /api/moon-settings: the whole settings
|
||||||
|
-- object, as terrain.lua's DEFAULTS shape it).
|
||||||
|
--
|
||||||
|
-- Left: the moon (modules/moon, the game's own viewport and generator) with the last
|
||||||
|
-- build's technique, vertices and time. Right, a fixed bar: the seed (a moon id: a field,
|
||||||
|
-- New seed, and presets: the lab's seed and the player's moons), Defaults, Reset (back to
|
||||||
|
-- the saved settings), the save status and Save; under it the settings in a scroll area,
|
||||||
|
-- grouped like the web page: a slider and a number field per number, a hex field and a
|
||||||
|
-- swatch per color, a toggle per flag, a button row for the technique.
|
||||||
|
--
|
||||||
|
-- Rebuilds: every change marks the moon pending; update hands the settings to the moon
|
||||||
|
-- (a new table: moon.script compares by identity) once the moon isn't making a mesh
|
||||||
|
-- (moon.building), so a drag rebuilds as often as a build takes and once more after the
|
||||||
|
-- last move. Render values (bloom, atmosphere, ...) don't change the mesh: they show at
|
||||||
|
-- once (moon.script's cache).
|
||||||
|
--
|
||||||
|
-- Opening reads GET /api/moon-settings; Save posts and makes them the game's settings
|
||||||
|
-- (moon.settings), so the game's viewport shows them at once. Closing without Save puts
|
||||||
|
-- the saved settings back. State on g.lab (survives rebuilds); the controls are one kept
|
||||||
|
-- block, rebuilt only when g.lab.rev changes (load, Defaults, Reset, a toggle or the
|
||||||
|
-- technique) or the size does: a slider's drag never rebuilds it.
|
||||||
|
--
|
||||||
|
-- Test ids: "lab:seed-new", "lab:seed:<seed>", "lab:defaults", "lab:reset", "lab:save",
|
||||||
|
-- "slider:<group>.<key>" (group "root" for top-level keys), "field:lab:<group>.<key>",
|
||||||
|
-- "field:lab-seed", "lab:tech:<name>", "lab:bool:<group>.<key>".
|
||||||
|
|
||||||
|
local ui = require("modules.ui")
|
||||||
|
local flex = require("modules.flex")
|
||||||
|
local fg = require("modules.flex_gui")
|
||||||
|
local theme = require("modules.theme")
|
||||||
|
local field = require("modules.field")
|
||||||
|
local api = require("modules.api")
|
||||||
|
local test = require("modules.test")
|
||||||
|
local terrain = require("modules.moon.terrain")
|
||||||
|
local moon_view = require("modules.moon.moon")
|
||||||
|
local panel = require("components.panel")
|
||||||
|
local scroll = require("components.scroll")
|
||||||
|
local button = require("components.button")
|
||||||
|
local slider = require("components.slider")
|
||||||
|
|
||||||
|
local S = theme.size
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
-- The web page's schema: [key, min, max, step, label] for sliders, [key, "color", label],
|
||||||
|
-- [key, "bool", label], [key, "select", label] (the technique).
|
||||||
|
local SCHEMA = {
|
||||||
|
{ title = "Mesh", fields = { { "resolution", 16, 256, 8 }, { "technique", "select", "Base technique" } } },
|
||||||
|
{ title = "Shape noise", path = "shape", fields = {
|
||||||
|
{ "strength", 0, 0.2, 0.001 }, { "frequency", 0.1, 5, 0.01 }, { "octaves", 1, 8, 1 },
|
||||||
|
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "minValue", 0, 1, 0.01, "Flatten below" },
|
||||||
|
} },
|
||||||
|
{ title = "Ridges", path = "ridge", fields = {
|
||||||
|
{ "strength", 0, 0.15, 0.001 }, { "frequency", 0.1, 6, 0.01 }, { "octaves", 1, 8, 1 },
|
||||||
|
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "sharpness", 0.5, 6, 0.05 },
|
||||||
|
{ "gain", 0, 2, 0.01, "Octave weight" }, { "minValue", 0, 1, 0.01, "Flatten below" },
|
||||||
|
} },
|
||||||
|
{ title = "Domain warp", note = "Used by the warped technique.", path = "warp", fields = {
|
||||||
|
{ "strength", 0, 2, 0.01 }, { "frequency", 0.1, 4, 0.01 }, { "octaves", 1, 5, 1 },
|
||||||
|
} },
|
||||||
|
{ title = "Craters", path = "craters", fields = {
|
||||||
|
{ "count", 0, 2000, 1 }, { "minRadius", 0.005, 0.1, 0.001 }, { "maxRadius", 0.02, 0.6, 0.001 },
|
||||||
|
{ "sizeBias", 1, 8, 0.05, "Size bias (power law)" }, { "depth", 0, 1.5, 0.01 }, { "rimWidth", 0, 2, 0.01 },
|
||||||
|
{ "rimSteepness", 0, 1.5, 0.01 }, { "floorHeight", -1, 0, 0.01 }, { "smoothness", 0, 1, 0.01 },
|
||||||
|
{ "ejecta", 0, 1, 0.01, "Ejecta halo" },
|
||||||
|
} },
|
||||||
|
{ title = "Shading", path = "shading", fields = {
|
||||||
|
{ "low", "color", "Low ground" }, { "mid", "color", "Mid" }, { "high", "color", "High ground" },
|
||||||
|
{ "steep", "color", "Steep slopes" }, { "steepThreshold", 0, 0.6, 0.005 }, { "steepBlend", 0, 0.3, 0.005 },
|
||||||
|
{ "patch", "color", "Dark plains" }, { "patchAmount", 0, 1, 0.01, "Plains amount" },
|
||||||
|
{ "patchFrequency", 0.2, 5, 0.01, "Plains frequency" }, { "grain", 0, 0.3, 0.005 },
|
||||||
|
} },
|
||||||
|
{ title = "Per-moon variation", note = "Each moon is varied by its id by up to these amounts, either way.", path = "variation", fields = {
|
||||||
|
{ "hue", 0, 180, 1, "Hue (degrees)" }, { "saturation", 0, 1, 0.01 }, { "lightness", 0, 0.4, 0.005 },
|
||||||
|
{ "shape", 0, 1, 0.01, "Noise strength" }, { "frequency", 0, 1, 0.01, "Noise frequency" },
|
||||||
|
{ "craters", 0, 1, 0.01, "Crater count and size" }, { "technique", "bool", "Pick technique per moon" },
|
||||||
|
} },
|
||||||
|
{ title = "Render", path = "render", fields = {
|
||||||
|
{ "bloomStrength", 0, 2, 0.01 }, { "bloomRadius", 0, 1, 0.01 }, { "bloomThreshold", 0, 1.5, 0.01 },
|
||||||
|
{ "atmosphere", "color", "Atmosphere" }, { "atmosphereIntensity", 0, 3, 0.01 },
|
||||||
|
{ "atmosphereFollowsHue", "bool", "Atmosphere follows moon hue" }, { "sunIntensity", 0, 8, 0.05 },
|
||||||
|
{ "ambient", 0, 1, 0.01 }, { "exposure", 0.2, 3, 0.01 }, { "detail", 0, 0.03, 0.0005, "Surface detail" },
|
||||||
|
{ "rotationSpeed", 0, 1, 0.005 }, { "stars", "bool", "Stars" },
|
||||||
|
} },
|
||||||
|
}
|
||||||
|
|
||||||
|
local SEED = "moon-lab"
|
||||||
|
local ROW_H = 30
|
||||||
|
local LABEL_W = 150
|
||||||
|
local NUM_W = 76
|
||||||
|
local PAD = S.panel_pad
|
||||||
|
local CTRL_MIN, CTRL_MAX = 440, 560
|
||||||
|
local STATS_H = 22
|
||||||
|
|
||||||
|
local function vec(x, y)
|
||||||
|
return vmath.vector3(x, y, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clone(t)
|
||||||
|
if type(t) ~= "table" then
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
local c = {}
|
||||||
|
for k, v in pairs(t) do
|
||||||
|
c[k] = clone(v)
|
||||||
|
end
|
||||||
|
return c
|
||||||
|
end
|
||||||
|
|
||||||
|
-- "minValue" -> "Min value" (the web's label()).
|
||||||
|
local function label_of(key)
|
||||||
|
local s = key:gsub("(%u)", function(c) return " " .. c:lower() end)
|
||||||
|
return s:sub(1, 1):upper() .. s:sub(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A value as the web's <output> shows it: whole for whole steps, else the step's decimals
|
||||||
|
-- (at most 3... the web's rule; 0.0005 shows 4 so the step is visible).
|
||||||
|
local function fmt(v, step)
|
||||||
|
if step >= 1 then
|
||||||
|
return string.format("%d", math.floor(v + 0.5))
|
||||||
|
end
|
||||||
|
local dec = #(tostring(step):match("%.(%d+)") or "00")
|
||||||
|
return string.format("%." .. math.min(4, dec) .. "f", v)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function group_of(settings, path)
|
||||||
|
return path and settings[path] or settings
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- state
|
||||||
|
|
||||||
|
local function lab(g)
|
||||||
|
g.lab = g.lab or { seed = SEED, rev = 0 }
|
||||||
|
return g.lab
|
||||||
|
end
|
||||||
|
|
||||||
|
local function status(L)
|
||||||
|
if L.saving then
|
||||||
|
return "Saving…"
|
||||||
|
elseif L.error then
|
||||||
|
return L.error
|
||||||
|
elseif not L.loaded then
|
||||||
|
return "Loading…"
|
||||||
|
end
|
||||||
|
return terrain.serialize(L.draft) == terrain.serialize(L.saved) and "Saved" or "Unsaved changes"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_status(L)
|
||||||
|
if L.w and L.w.status then
|
||||||
|
ui.style_text(L.w.status, "small", status(L))
|
||||||
|
ui.fit_text(L.w.status, L.w.status_w)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A value changed: the moon gets it on the next free frame (update).
|
||||||
|
local function changed(L)
|
||||||
|
L.pending = true
|
||||||
|
L.error = nil
|
||||||
|
show_status(L)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The controls show other values (loaded, Defaults, Reset, a toggle): rebuild them.
|
||||||
|
local function rebuild(g)
|
||||||
|
local L = lab(g)
|
||||||
|
L.rev = L.rev + 1
|
||||||
|
L.pending = true
|
||||||
|
L.error = nil
|
||||||
|
g.dirty = true
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.open(g)
|
||||||
|
local L = lab(g)
|
||||||
|
L.saved = terrain.merge_settings(moon_view.settings)
|
||||||
|
L.draft = clone(L.saved)
|
||||||
|
L.loaded, L.error, L.saving = false, nil, false
|
||||||
|
L.pending = true
|
||||||
|
api.get_moon_settings(function(err, data)
|
||||||
|
if g.lab ~= L then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if err then
|
||||||
|
L.loaded = true
|
||||||
|
L.error = "Couldn't load the saved settings (" .. err .. ")"
|
||||||
|
show_status(L)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
L.saved = terrain.merge_settings(data)
|
||||||
|
L.draft = clone(L.saved)
|
||||||
|
L.loaded = true
|
||||||
|
moon_view.settings = clone(L.saved) -- (fresher than the game's copy)
|
||||||
|
rebuild(g)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Leaving: the game's moon goes back to the saved settings (unsaved tweaks were only
|
||||||
|
-- for the lab).
|
||||||
|
function M.close(g)
|
||||||
|
local L = g.lab
|
||||||
|
if L and L.saved then
|
||||||
|
moon_view.settings = clone(L.saved)
|
||||||
|
end
|
||||||
|
g.lab = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function save(g)
|
||||||
|
local L = lab(g)
|
||||||
|
if L.saving then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
L.saving, L.error = true, nil
|
||||||
|
show_status(L)
|
||||||
|
local sent = clone(L.draft)
|
||||||
|
api.save_moon_settings(sent, function(err)
|
||||||
|
-- The game's moons take the saved settings even if the lab was left meanwhile.
|
||||||
|
if err then
|
||||||
|
g.toasts:push("Moon settings not saved: " .. err, "error", true)
|
||||||
|
else
|
||||||
|
moon_view.settings = clone(sent)
|
||||||
|
g.toasts:push("Moon settings saved: the game's moons use them now", "save-code", false)
|
||||||
|
end
|
||||||
|
if g.lab ~= L then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
L.saving = false
|
||||||
|
if err then
|
||||||
|
L.error = "Save failed: " .. err
|
||||||
|
else
|
||||||
|
L.saved = sent
|
||||||
|
end
|
||||||
|
show_status(L)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- controls
|
||||||
|
|
||||||
|
local function small_button(parent, label, id, on_click, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local bopts = { small = true, variant = opts.variant, on = opts.on }
|
||||||
|
return fg.button(parent, label, bopts, function(b, nodes)
|
||||||
|
button.bind(opts.g, b, nodes, { id = id, on_click = on_click })
|
||||||
|
end, opts.style)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One setting's row: label, then its control.
|
||||||
|
local function setting_row(g, L, parent, zone, path, f)
|
||||||
|
local key, a, b, step, name = f[1], f[2], f[3], f[4], f[5]
|
||||||
|
local kind = type(a) == "string" and a or "number"
|
||||||
|
local text = (kind ~= "number" and b or name) or label_of(key)
|
||||||
|
local gid = (path or "root") .. "." .. key
|
||||||
|
local target = group_of(L.draft, path)
|
||||||
|
local kids = {}
|
||||||
|
if kind ~= "select" then
|
||||||
|
kids[1] = fg.label(parent, text, "small", ROW_H, { width = LABEL_W, shrink = 0, fit = true })
|
||||||
|
end
|
||||||
|
if kind == "number" then
|
||||||
|
local o = { min = a, max = b, step = step }
|
||||||
|
local fkey = "lab:" .. gid
|
||||||
|
local f_state = field.get(g, fkey)
|
||||||
|
f_state.text = fmt(target[key], step)
|
||||||
|
local ctl = {}
|
||||||
|
kids[#kids + 1] = fg.made({ grow = 1, height = ROW_H, padding_right = 10 }, function(p, x, y, w, h)
|
||||||
|
ctl.slider = slider.new(g, p, x + 6, y, w - 16, h, { min = a, max = b, step = step, value = target[key],
|
||||||
|
id = "slider:" .. gid, label = text, zone = zone,
|
||||||
|
on_change = function(v)
|
||||||
|
target[key] = v
|
||||||
|
if ctl.input then
|
||||||
|
ctl.syncing = true
|
||||||
|
ctl.input:set_text(fmt(v, step))
|
||||||
|
f_state.text = fmt(v, step)
|
||||||
|
ctl.syncing = false
|
||||||
|
end
|
||||||
|
changed(L)
|
||||||
|
end })
|
||||||
|
end)
|
||||||
|
kids[#kids + 1] = fg.made({ width = NUM_W, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
|
||||||
|
ctl.input = field.new(g, p, fkey, x, y, w, h, { font = "mono", px = 13, allowed = "[%d%.%-]", max_len = 9,
|
||||||
|
min = o.min, max = o.max,
|
||||||
|
on_change = function(t)
|
||||||
|
local v = tonumber(t)
|
||||||
|
if ctl.syncing or not v then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
v = math.max(o.min, math.min(o.max, v))
|
||||||
|
target[key] = v
|
||||||
|
ctl.slider:set(v)
|
||||||
|
changed(L)
|
||||||
|
end })
|
||||||
|
end)
|
||||||
|
elseif kind == "color" then
|
||||||
|
local fkey = "lab:" .. gid
|
||||||
|
field.get(g, fkey).text = target[key]
|
||||||
|
local sw
|
||||||
|
kids[#kids + 1] = fg.made({ width = 26, height = 22, shrink = 0 }, function(p, x, y, w, h)
|
||||||
|
sw = ui.rect(p, x, y, w, h, "line_hi")
|
||||||
|
-- (the setting's own color, not a theme color: the swatch shows the data)
|
||||||
|
local c = terrain.hex_to_rgb(target[key])
|
||||||
|
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
|
||||||
|
end)
|
||||||
|
kids[#kids + 1] = fg.made({ width = 100, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
|
||||||
|
field.new(g, p, fkey, x + 10, y, w - 10, h, { font = "mono", px = 13, allowed = "[#%x]", max_len = 7,
|
||||||
|
on_change = function(t)
|
||||||
|
if t:match("^#%x%x%x%x%x%x$") then
|
||||||
|
target[key] = t:lower()
|
||||||
|
local c = terrain.hex_to_rgb(t)
|
||||||
|
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
|
||||||
|
changed(L)
|
||||||
|
end
|
||||||
|
end })
|
||||||
|
end)
|
||||||
|
elseif kind == "bool" then
|
||||||
|
local on = target[key] == true
|
||||||
|
kids[#kids + 1] = small_button(parent, on and "On" or "Off", "lab:bool:" .. gid, function()
|
||||||
|
target[key] = not target[key]
|
||||||
|
rebuild(g)
|
||||||
|
end, { g = g, on = on, variant = on and nil or "ghost", style = { width = 72 } })
|
||||||
|
else -- the technique
|
||||||
|
local row = {}
|
||||||
|
for _, t in ipairs(terrain.TECHNIQUES) do
|
||||||
|
local cur = target[key] == t
|
||||||
|
row[#row + 1] = small_button(parent, t, "lab:tech:" .. t, function()
|
||||||
|
target[key] = t
|
||||||
|
rebuild(g)
|
||||||
|
end, { g = g, variant = cur and "primary" or nil })
|
||||||
|
end
|
||||||
|
return fg.col({ gap = 6 }, {
|
||||||
|
fg.label(parent, text, "small", 20, { fit = true }),
|
||||||
|
fg.box({ wrap = true, gap = 6 }, row),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return fg.row({ height = ROW_H, align = "center" }, kids)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The settings, grouped like the web page, in a scroll area at (x, y), w x h.
|
||||||
|
local function settings_list(g, L, parent, x, y, w, h)
|
||||||
|
local area = scroll.area(g, parent, x, y, w, h, "moonlab")
|
||||||
|
local p = area.content
|
||||||
|
local groups = {}
|
||||||
|
for _, sec in ipairs(SCHEMA) do
|
||||||
|
local rows = { fg.label(p, sec.title, "h3", 22) }
|
||||||
|
if sec.note then
|
||||||
|
rows[#rows + 1] = fg.label(p, sec.note, "small", 20, { fit = true })
|
||||||
|
end
|
||||||
|
for _, f in ipairs(sec.fields) do
|
||||||
|
rows[#rows + 1] = setting_row(g, L, p, area.view, sec.path, f)
|
||||||
|
end
|
||||||
|
groups[#groups + 1] = fg.col({ gap = 4 }, rows)
|
||||||
|
end
|
||||||
|
local tree = fg.col({ gap = 18, width = w, padding = { 4, 14, 16, 2 } }, groups)
|
||||||
|
flex.solve(tree, 0, 0)
|
||||||
|
fg.build(tree, p)
|
||||||
|
area:finish(tree.h)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The fixed bar over the settings: seed, presets, Defaults / Reset / status / Save.
|
||||||
|
-- Returns its height.
|
||||||
|
local function toolbar(g, L, parent, x, y, w)
|
||||||
|
local st = g.state or {}
|
||||||
|
field.get(g, "lab-seed").text = L.seed
|
||||||
|
local function seed_to(s)
|
||||||
|
L.seed = s
|
||||||
|
field.get(g, "lab-seed").text = s
|
||||||
|
rebuild(g)
|
||||||
|
end
|
||||||
|
local presets = { small_button(parent, SEED, "lab:seed:" .. SEED, function() seed_to(SEED) end, { g = g }) }
|
||||||
|
for i, m in ipairs(st.moons or {}) do
|
||||||
|
if i > 3 then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local id = tostring(m.id)
|
||||||
|
presets[#presets + 1] = small_button(parent, m.name or id, "lab:seed:" .. id, function() seed_to(id) end,
|
||||||
|
{ g = g, style = { max_width = 160 } })
|
||||||
|
end
|
||||||
|
local status_w = 170
|
||||||
|
local tree = fg.col({ width = w, gap = 10 }, {
|
||||||
|
fg.label(parent, "Seed (moon id)", "cap", 14),
|
||||||
|
fg.row({ gap = 8, height = S.button_sm_h, align = "center" }, {
|
||||||
|
fg.made({ grow = 1, height = S.button_sm_h }, function(p, bx, by, bw, bh)
|
||||||
|
field.new(g, p, "lab-seed", bx, by, bw, bh, { font = "mono", px = 13, hint = SEED, max_len = 64,
|
||||||
|
on_change = function(t)
|
||||||
|
L.seed = t ~= "" and t or SEED
|
||||||
|
L.pending = true
|
||||||
|
end })
|
||||||
|
end),
|
||||||
|
small_button(parent, "New seed", "lab:seed-new", function()
|
||||||
|
seed_to(string.format("%08x", math.random(0, 0x7fffffff)))
|
||||||
|
end, { g = g }),
|
||||||
|
}),
|
||||||
|
fg.box({ wrap = true, gap = 6 }, presets),
|
||||||
|
fg.row({ gap = 8, height = S.button_h, align = "center" }, {
|
||||||
|
small_button(parent, "Defaults", "lab:defaults", function()
|
||||||
|
L.draft = clone(terrain.DEFAULTS)
|
||||||
|
rebuild(g)
|
||||||
|
end, { g = g }),
|
||||||
|
small_button(parent, "Reset", "lab:reset", function()
|
||||||
|
L.draft = clone(L.saved)
|
||||||
|
rebuild(g)
|
||||||
|
end, { g = g }),
|
||||||
|
fg.made({ grow = 1, height = 20, shrink = 1 }, function(p, bx, by, bw, bh)
|
||||||
|
L.w.status = ui.text(p, "", "small", bx + bw - 8, by + bh / 2, gui.PIVOT_E)
|
||||||
|
L.w.status_w = math.min(status_w, bw - 8)
|
||||||
|
show_status(L)
|
||||||
|
end),
|
||||||
|
fg.button(parent, "Save", { variant = "primary" }, function(b, nodes)
|
||||||
|
button.bind(g, b, nodes, { id = "lab:save", on_click = function() save(g) end })
|
||||||
|
end, { width = 110 }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
flex.solve(tree, x, y)
|
||||||
|
fg.build(tree, parent)
|
||||||
|
return tree.h
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------- build
|
||||||
|
|
||||||
|
-- The block's inputs (core/main keeps it while they stay the same).
|
||||||
|
function M.sig(g)
|
||||||
|
local L = lab(g)
|
||||||
|
return { L.rev, L.loaded }
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.build(g, parent, x, y, w, h)
|
||||||
|
local L = lab(g)
|
||||||
|
L.w = {}
|
||||||
|
local gap = S.gap
|
||||||
|
local cw = math.max(CTRL_MIN, math.min(CTRL_MAX, math.floor(w * 0.4)))
|
||||||
|
local mw = w - cw - gap
|
||||||
|
-- The moon.
|
||||||
|
local mp = panel.new(parent, x, y, mw, h)
|
||||||
|
local size = math.max(100, math.min(mw - PAD * 2, h - PAD * 3 - STATS_H))
|
||||||
|
local vx = math.floor((mw - size) / 2)
|
||||||
|
local vp = ui.rect(mp.body, vx, PAD, size, size, "viewport")
|
||||||
|
L.vp = vp
|
||||||
|
moon_view.show(L.seed, vp)
|
||||||
|
L.w.stats = ui.text(mp.body, "", "num_small", vx, PAD + size + PAD / 2 + STATS_H / 2)
|
||||||
|
L.w.stats_w = size
|
||||||
|
L.shown_build = nil
|
||||||
|
ui.text(mp.body, "Drag to rotate · scroll to zoom", "cap", vx + size, PAD + size + PAD / 2 + STATS_H / 2, gui.PIVOT_E)
|
||||||
|
-- The settings.
|
||||||
|
local cp = panel.new(parent, x + mw + gap, y, cw, h)
|
||||||
|
local inner = cw - PAD * 2
|
||||||
|
local th = toolbar(g, L, cp.body, PAD, PAD, inner)
|
||||||
|
local ly = PAD + th + 12
|
||||||
|
ui.rect(cp.body, PAD, ly - 6, inner, 1, "line")
|
||||||
|
settings_list(g, L, cp.body, PAD, ly, inner + PAD - 4, h - ly - PAD)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Every frame while the lab is shown: hand the moon pending settings when it's free, and
|
||||||
|
-- show the last build.
|
||||||
|
function M.update(g, dt)
|
||||||
|
local L = g.lab
|
||||||
|
if not L or not L.vp then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if L.pending and not moon_view.building then
|
||||||
|
L.pending = false
|
||||||
|
moon_view.settings = clone(L.draft)
|
||||||
|
moon_view.show(L.seed, L.vp)
|
||||||
|
moon_view.building = true -- (until moon.script looks: one handed over at a time)
|
||||||
|
end
|
||||||
|
local b = moon_view.last_build
|
||||||
|
if b and b ~= L.shown_build and L.w and L.w.stats then
|
||||||
|
L.shown_build = b
|
||||||
|
local verts = tostring(b.vertices):reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "")
|
||||||
|
ui.style_text(L.w.stats, "num_small", string.format("%s · %s vertices · %.0f ms", b.technique, verts, b.ms))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -273,6 +273,35 @@ function M.grade(g)
|
|||||||
return st.moonGrade
|
return st.moonGrade
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- What the rows show (widgets/moon_row: name, grade, rating, status, warning, ready
|
||||||
|
-- badge, selection), both lists: the moon list's block signature has this instead of the
|
||||||
|
-- moons themselves, so a change the rows don't show (a well's projection, a drill's end)
|
||||||
|
-- keeps the list.
|
||||||
|
function M.rows_sig(self)
|
||||||
|
local st, v = self.state, self.view
|
||||||
|
local out, n = {}, 0
|
||||||
|
for _, list in ipairs({ st.moons, st.past }) do
|
||||||
|
for _, m in ipairs(list) do
|
||||||
|
local dry = not m.finished and #(m.wells or {}) > 0
|
||||||
|
if dry then
|
||||||
|
for _, w in ipairs(m.wells) do
|
||||||
|
if w.supply > 0 then
|
||||||
|
dry = false
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
n = n + 1
|
||||||
|
out[n] = { m.id, m.name, m.grade or false, m.rating or false, m.finished or false, m.mined or false,
|
||||||
|
m.finishedOn or false, m.finishedAt or false, dry, m.drills or 0, m.over or false,
|
||||||
|
not m.finished and ready.drills(self, m.id) or m.ready or 0, (m.selected and v.section == "moons") or false }
|
||||||
|
end
|
||||||
|
n = n + 1
|
||||||
|
out[n] = "|"
|
||||||
|
end
|
||||||
|
return blocks.sig(out)
|
||||||
|
end
|
||||||
|
|
||||||
function M.build(self, H, parent, x, y, w, h)
|
function M.build(self, H, parent, x, y, w, h)
|
||||||
local st = self.state
|
local st = self.state
|
||||||
local v = self.view
|
local v = self.view
|
||||||
|
|||||||
+677
-230
@@ -1,8 +1,9 @@
|
|||||||
-- The Moons section (main panel): the selected moon (id, name, class and rating stars,
|
-- The Moons section (main panel): the selected moon (id, name, class and rating stars,
|
||||||
-- description, Claim), its wells table (supply bars, drill timer bars, claim / countdown /
|
-- description, Claim N drills), its wells as cards with every action on a well (deploy,
|
||||||
-- redrill per well, the multi-well row); a mined-out
|
-- claim, claim and redeploy, redrill: "well cards" below), the multi-well card, the tray
|
||||||
-- moon's summary; the first-moon pitch when there is none. Also the well helpers the
|
-- of idle drills and selling the moon; a mined-out moon's summary; the first-moon pitch
|
||||||
-- aside's well panel shares (supply_bar, supply_max, tool_name).
|
-- when there is none. The aside only shows facts (aside.lua). Also the well helpers the
|
||||||
|
-- aside shares (supply_bar, supply_max, tool_name, short_name).
|
||||||
|
|
||||||
local theme = require("modules.theme")
|
local theme = require("modules.theme")
|
||||||
local ui = require("modules.ui")
|
local ui = require("modules.ui")
|
||||||
@@ -15,6 +16,10 @@ local scroll = require("components.scroll")
|
|||||||
local ready = require("screens.game.core.ready")
|
local ready = require("screens.game.core.ready")
|
||||||
local flex = require("modules.flex")
|
local flex = require("modules.flex")
|
||||||
local fg = require("modules.flex_gui")
|
local fg = require("modules.flex_gui")
|
||||||
|
local images = require("modules.images")
|
||||||
|
local test = require("modules.test")
|
||||||
|
local predict = require("modules.predict")
|
||||||
|
local blocks = require("modules.blocks")
|
||||||
|
|
||||||
local S = theme.size
|
local S = theme.size
|
||||||
|
|
||||||
@@ -100,10 +105,10 @@ function M.track_drills(g)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A tool's name and tier by id.
|
-- A tool's name and its levels tag (ui.levels_tag: " Y2 C1", "" for a base tool) by id.
|
||||||
function M.tool_name(st, id)
|
function M.tool_name(st, id)
|
||||||
local t = find(st.tools, id)
|
local t = find(st.tools, id)
|
||||||
return t and t.name or "Tool", t and t.tier or 0
|
return t and t.name or "Tool", ui.levels_tag(t)
|
||||||
end
|
end
|
||||||
|
|
||||||
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||||
@@ -169,220 +174,629 @@ local function past_moon(g, root, px, py, inner, moon, H)
|
|||||||
})
|
})
|
||||||
fg.build(flex.solve(tree, px, py), root)
|
fg.build(flex.solve(tree, px, py), root)
|
||||||
end
|
end
|
||||||
-- Timer bars for a drill queue, oldest first (the web's drill-stack): thin bars 40 wide
|
|
||||||
-- stacked, filling while it runs, full when ready, tinted by what it gets (ok, short,
|
---------------------------------------------------------------- well cards
|
||||||
-- none); a multi-well drill has a diamond cap. At most 4 bars, then "+N" beside them.
|
-- The wells as cards (Drilling B · Well cards): every action on a well is on its card,
|
||||||
-- row: the row's key (a multi-well drill has a bar on every row it reaches). y is the
|
-- the aside only shows. A card: the resource and its supply, the supply bar and a line
|
||||||
-- stack's middle.
|
-- of what the drills leave, then a stack of slots, all SLOT_H tall: the deploy slot
|
||||||
local MAX_BARS, BAR_W, BAR_H, BAR_GAP = 4, 40, 3, 2
|
-- first (it keeps its spot while you deploy again and again: new drills go under it),
|
||||||
local MORE = { font = "mono", px = 11, color = "muted" }
|
-- then a slot per drill, oldest first (time left and its bar; when it's done, gold with
|
||||||
local function drill_bars(g, parent, x, y, drills, row_key)
|
-- Claim and Redeploy on it), then one slot for the idle drills that last mined the well
|
||||||
local n = math.min(#drills, MAX_BARS)
|
-- (Redrill). Claiming one slot claims every ready drill of the well (claim-well); the
|
||||||
local top = y - math.floor((n * (BAR_H + BAR_GAP) - BAR_GAP) / 2)
|
-- next slot slides into its place. Clicking the card (not a button) selects the well:
|
||||||
for i = 1, n do
|
-- the aside shows its facts.
|
||||||
local d = drills[i]
|
--
|
||||||
local color = d.state == "short" and "ready" or "cyan"
|
-- Deploying: the tray under the cards lists the idle drills (grouped like copies: same
|
||||||
local by = top + (i - 1) * (BAR_H + BAR_GAP)
|
-- name, track levels, yield, cycle and reach). Clicking a tile deploys one of them to the
|
||||||
-- Keyed by row and tool (one drill each), so a restarted or corrected drill eases.
|
-- selected well (none selected: the first well it has room on). A well's deploy slot sends
|
||||||
bar.timer(g, parent, x, by, BAR_W, BAR_H, { start = d.start, ends = d["end"], ready = d.ready, color = color,
|
-- the first drill in the tray that has room there, multi-well ones too (smallest first;
|
||||||
none = d.state == "none", key = "bar:" .. row_key .. ":" .. d.toolId })
|
-- predict.room: what it would get after the moon's drills).
|
||||||
if (d.reach or 1) > 1 then
|
|
||||||
-- The diamond cap (app.css .drill-bar.is-wide::after).
|
local SLOT_H, SLOT_GAP = 60, 6
|
||||||
local cap = ui.rect(parent, 0, 0, 5, 5, d.state == "none" and "muted" or color)
|
local CARD_MIN, CARD_GAP, CARD_PAD = 210, 12, 12
|
||||||
gui.set_pivot(cap, gui.PIVOT_CENTER)
|
local TILE_MIN, TILE_H = 150, 52
|
||||||
gui.set_position(cap, vec(x + BAR_W + 1, -(by + 1)))
|
local SLOT_BTN_H = 26
|
||||||
gui.set_rotation(cap, vmath.quat_rotation_z(math.rad(45)))
|
local EPS = 1e-9
|
||||||
end
|
|
||||||
|
local NAME13 = { font = "sans_semi", px = 13, color = "text" }
|
||||||
|
local M12 = { font = "mono", px = 12, color = "muted" }
|
||||||
|
local S12 = { font = "sans", px = 12, color = "muted" }
|
||||||
|
local S11 = { font = "sans", px = 11, color = "muted" }
|
||||||
|
|
||||||
|
-- A tool's short name ("PD-1 T0"): its code (else the last word of its name), and its
|
||||||
|
-- track levels when it has any ("PD-1 Y2 C1", ui.levels_tag). t: a state.tools entry
|
||||||
|
-- (nil: "Drill").
|
||||||
|
function M.short_name(t)
|
||||||
|
if not t then
|
||||||
|
return "Drill"
|
||||||
end
|
end
|
||||||
|
local s = (t.code and t.code ~= "") and t.code or ((t.name or "Drill"):match("(%S+)$") or "Drill")
|
||||||
|
s = s .. ui.levels_tag(t)
|
||||||
|
return s
|
||||||
end
|
end
|
||||||
|
|
||||||
local function drill_label(st, drills)
|
-- The web's short wording of a reach ("3 wells", "whole moon"; nil for one).
|
||||||
if #drills == 0 then
|
local function reach_short(st, n)
|
||||||
return ""
|
if not n or n <= 1 then
|
||||||
elseif #drills == 1 then
|
return nil
|
||||||
local name, tier = M.tool_name(st, drills[1].toolId)
|
|
||||||
return name:match("(%S+)$") .. " T" .. tier
|
|
||||||
end
|
end
|
||||||
return ui.plural(#drills, "drill")
|
local list = st.catalog and st.catalog.reach or {}
|
||||||
|
local pick = list[#list]
|
||||||
|
for _, r in ipairs(list) do
|
||||||
|
if r.reach == n then
|
||||||
|
pick = r
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return pick and pick.short ~= "" and pick.short or (n .. " wells")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The drill column of a row, built at its width (52 tall): the stacked timer bars for
|
-- Idle tools grouped like copies (name, levels, yield, cycle, reach; read generically), in
|
||||||
-- every drill on it, multi-well ones included (the web's Queue), and the label of its
|
-- order of yield, then key: { key, tool (one idle copy), count }.
|
||||||
-- own drills (DrillLabel) beside them, or under them (after "+N") when there's no room
|
function M.idle_groups(st)
|
||||||
-- (decided at its width). Its tooltip (DrillsTitle): each drill, oldest first, and what
|
local out, by = {}, {}
|
||||||
-- it gets. pad: room kept before the status buttons.
|
for _, t in ipairs(st.tools or {}) do
|
||||||
local function drill_cell(g, queue, fates, label, row_key, pad)
|
if not t.stakeId then
|
||||||
return made({ grow = 1, shrink = 1, padding_right = pad }, function(p, x, y, w, h)
|
local k = table.concat({ t.code or t.name or "", ui.levels_tag(t), tostring(t.yield),
|
||||||
local dw = w - pad
|
tostring(t.cycleSecs), tostring(t.reach or 1) }, ":")
|
||||||
if #fates > 0 then
|
local grp = by[k]
|
||||||
tooltip.add(ui.rect(p, x, y + 6, math.max(40, dw), h - 12, "none"), table.concat(fates, "\n"))
|
if not grp then
|
||||||
end
|
grp = { key = k, tool = t, count = 0 }
|
||||||
local n = math.min(#queue, MAX_BARS)
|
by[k], out[#out + 1] = grp, grp
|
||||||
local stack_h = n * (BAR_H + BAR_GAP)
|
|
||||||
local more = #queue - n
|
|
||||||
local function bars(beside)
|
|
||||||
local more_l = beside and more > 0 and fg.label(p, "+" .. more, MORE, 16) or nil
|
|
||||||
return row({ shrink = 0, align = "center" }, {
|
|
||||||
made({ width = BAR_W + 4, height = stack_h, shrink = 0 }, function(q, bx, by)
|
|
||||||
drill_bars(g, q, bx, by + math.floor(stack_h / 2), queue, row_key)
|
|
||||||
end),
|
|
||||||
more_l and space(2) or nil, more_l,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
local tree
|
|
||||||
if #queue == 0 then
|
|
||||||
tree = row({ height = h, align = "center" }, {
|
|
||||||
fg.label(p, "Empty", { font = "sans", px = 14, color = "muted" }, 16, { fit = true, shrink = 1 }),
|
|
||||||
})
|
|
||||||
else
|
|
||||||
local style = { font = "sans", px = 12, color = "text_2" }
|
|
||||||
local probe = fg.label(p, label, style, 16)
|
|
||||||
local lw = probe.measure()
|
|
||||||
gui.delete_node(probe.node)
|
|
||||||
if BAR_W + 4 + (more > 0 and 18 or 0) + 6 + lw <= dw then
|
|
||||||
tree = row({ height = h, align = "center" }, {
|
|
||||||
bars(true), space(6), fg.label(p, label, style, 16, { fit = true, shrink = 1 }),
|
|
||||||
})
|
|
||||||
else
|
|
||||||
-- (narrow: "+N" leads the label under the bars)
|
|
||||||
tree = col({ height = h, padding_top = 13, align = "start" }, {
|
|
||||||
made({ width = BAR_W + 4, height = stack_h, shrink = 0 }, function(q, bx, by)
|
|
||||||
drill_bars(g, q, bx, by + math.floor(stack_h / 2), queue, row_key)
|
|
||||||
end),
|
|
||||||
space(0, 1),
|
|
||||||
fg.label(p, (more > 0 and ("+" .. more .. " ") or "") .. label, style, 16, { fit = true, shrink = 1 }),
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
grp.count = grp.count + 1
|
||||||
end
|
end
|
||||||
fg.build(flex.solve(tree, x, y, dw), p)
|
end
|
||||||
|
-- (in a fixed order, not the server's list's: a claimed drill coming back idle mustn't
|
||||||
|
-- shuffle the tiles; smallest yield first, so an unpicked deploy slot sends the
|
||||||
|
-- smallest drill with room)
|
||||||
|
table.sort(out, function(a, b)
|
||||||
|
local ya, yb = a.tool.yield or 0, b.tool.yield or 0
|
||||||
|
if ya ~= yb then
|
||||||
|
return ya < yb
|
||||||
|
end
|
||||||
|
return a.key < b.key
|
||||||
|
end)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What a cycle of tool t would get on the well after the moon's drills (predict.room), and
|
||||||
|
-- its full amount there (the yield with the resource's live boost).
|
||||||
|
local function gets_here(st, well, t)
|
||||||
|
local boosts = st.rules and st.rules.boosts and st.rules.boosts.yield or {}
|
||||||
|
local amount = (t.yield or 0) * (boosts[well.resource] or 1)
|
||||||
|
return predict.room(st, well.id, amount, t.reach or 1) or amount, amount
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The group a well's deploy slot sends: the tray's pick (explicit = true, even when it has
|
||||||
|
-- no room there), else the first one-well group with room. Returns group, here, amount,
|
||||||
|
-- explicit.
|
||||||
|
local function deploy_choice(g, st, well, groups)
|
||||||
|
for _, grp in ipairs(groups) do
|
||||||
|
if grp.key == g.deploy_pick then
|
||||||
|
local here, amount = gets_here(st, well, grp.tool)
|
||||||
|
return grp, here, amount, true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, grp in ipairs(groups) do
|
||||||
|
-- (multi-well drills too: they deploy here like any other, then mine the next wells)
|
||||||
|
local here, amount = gets_here(st, well, grp.tool)
|
||||||
|
if here > EPS then
|
||||||
|
return grp, here, amount, false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- An action button's test id (core/hooks: "act:<name>?<sorted params>").
|
||||||
|
local function act_id(name, params)
|
||||||
|
local keys = {}
|
||||||
|
for k, v in pairs(params) do
|
||||||
|
keys[#keys + 1] = k .. "=" .. tostring(v)
|
||||||
|
end
|
||||||
|
table.sort(keys)
|
||||||
|
return "act:" .. name .. "?" .. table.concat(keys, "&")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A one-line label box in a slot or tile (cut with … when it doesn't fit).
|
||||||
|
local function fit(p, str, style, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
opts.fit, opts.shrink = true, opts.shrink or 1
|
||||||
|
return fg.label(p, str, style, 16, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Each part of the moon (its heading, a well's card, the multi-well card, the tray) is a
|
||||||
|
-- kept block (modules/blocks), built at its own origin and put in place: a deploy rebuilds
|
||||||
|
-- the cards it changed, not the panel. What a part shows is worked out first (its model,
|
||||||
|
-- from the state and our clock) and is its block's signature; the build draws only from
|
||||||
|
-- the model and the state, so a kept part shows what a rebuilt one would.
|
||||||
|
|
||||||
|
-- A kept part of the moon (key, sig: its model) at (x, y) of parent: fn(root) builds it
|
||||||
|
-- at root's origin and returns its height (also when it's kept). Parts nest: a card's
|
||||||
|
-- slots are parts of their own, so a card that changes keeps the slots that didn't.
|
||||||
|
local function part(g, key, sig, parent, x, y, fn)
|
||||||
|
local b, kept = blocks.open(g, key, sig, parent)
|
||||||
|
local h
|
||||||
|
if kept then
|
||||||
|
h = blocks.close(g, b)
|
||||||
|
else
|
||||||
|
h = blocks.close(g, b, fn(b.root))
|
||||||
|
end
|
||||||
|
gui.set_position(b.root, vec(x, -y))
|
||||||
|
return h
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The busy and ready-pop marks (core/hooks: g.inflight, g.ready_at) of these action keys,
|
||||||
|
-- for a part's signature.
|
||||||
|
local function marks(g, keys)
|
||||||
|
local out = {}
|
||||||
|
for i, k in ipairs(keys) do
|
||||||
|
local r = g.ready_at and g.ready_at[k]
|
||||||
|
out[i] = (g.inflight[k] and "b" or "") .. (r and tostring(r.t) or "")
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What a well's deploy slot shows (the group it sends and its two lines).
|
||||||
|
local function deploy_view(g, st, well, groups)
|
||||||
|
local grp, here, amount, explicit = deploy_choice(g, st, well, groups)
|
||||||
|
local ok = grp ~= nil and here > EPS
|
||||||
|
local v = { ok = ok, explicit = explicit or false, key = grp and grp.key or false,
|
||||||
|
name = grp and grp.tool.name or false, tool = grp and grp.tool.id or false }
|
||||||
|
if #groups == 0 then
|
||||||
|
v.title, v.sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
|
||||||
|
elseif not grp then
|
||||||
|
v.title, v.sub = "No room here", "The drills on it take all it has"
|
||||||
|
elseif not ok then
|
||||||
|
v.title, v.sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
|
||||||
|
else
|
||||||
|
local t = grp.tool
|
||||||
|
local reach = reach_short(st, t.reach)
|
||||||
|
v.title = "+ Deploy " .. M.short_name(t)
|
||||||
|
v.sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
|
||||||
|
if here < amount - EPS then
|
||||||
|
v.sub, v.color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a
|
||||||
|
-- cycle gets here, or why it can't (no idle drills, no room). v: deploy_view. It sends
|
||||||
|
-- the group's first idle copy at the click (a kept slot outlives the drill it was built
|
||||||
|
-- with); its test id is the well's: act:stake?target=<well>.
|
||||||
|
local function deploy_slot(g, well, v, mk, H)
|
||||||
|
return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
|
||||||
|
part(g, "mo:deploy:" .. well.id, blocks.sig(w, v, mk), p0, x0, y0, function(p)
|
||||||
|
local x, y = 0, 0
|
||||||
|
-- (off: a plain line and no fill, so it reads as greyed beside a live slot)
|
||||||
|
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not v.ok,
|
||||||
|
edge = not v.ok and "line" or nil, fill = not v.ok and { "cyan", 0 } or nil })
|
||||||
|
local tree = col({ width = w, height = SLOT_H, padding = { 0, 10 }, justify = "center", align = "center", gap = 2 }, {
|
||||||
|
fit(b, v.title, { font = "sans_semi", px = 13, color = v.ok and "cyan" or "muted", tracking = 0.06, upper = true }),
|
||||||
|
fit(b, v.sub, { font = "sans", px = 12, color = v.color or "muted" }),
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(tree, 0, 0), b)
|
||||||
|
if v.ok then
|
||||||
|
local function params()
|
||||||
|
for _, grp in ipairs(M.idle_groups(g.state)) do
|
||||||
|
if grp.key == v.key then
|
||||||
|
return { tool = grp.tool.id, target = well.id }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return { tool = v.tool, target = well.id } -- (gone since: the server says why)
|
||||||
|
end
|
||||||
|
H.action(g, b, bn, "stake", params, nil, nil, "act:stake?target=" .. well.id)
|
||||||
|
tooltip.add(b, "Deploy " .. (v.name or "a drill") .. (v.explicit and "" or " (the smallest idle drill with room here)"))
|
||||||
|
end
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Table columns (468 wide at 1280x800; wider tables spread the columns): the supply
|
-- What a drill's slot shows besides the drill d: dm = { name, due, reach } (drill_models).
|
||||||
-- column up to the web's 200 px, the drill column takes what the row's buttons leave.
|
-- A drill's slot (SLOT_H tall; slides in when it was just deployed): its short name and
|
||||||
local function table_cols(inner)
|
-- time left over its timer bar and what it gets; done (ready, or its end passed on our
|
||||||
local c2 = math.max(118, math.floor(inner * 0.25))
|
-- clock): gold, "+X ready", Claim (scope: claim-well or claim-wide) and, once unlocked,
|
||||||
return { c2, math.max(92, math.min(212, math.floor(inner * 0.2))), "1fr", "auto" }
|
-- Redeploy. n: its place among the group's ready slots (the first one's buttons have
|
||||||
|
-- the plain action ids). wait: the group's next drill to finish; where its Claim and
|
||||||
|
-- Redeploy will show is registered as "wait:claim?..." (like H.claim_wait's waiting
|
||||||
|
-- button; not focusable), for the test driver.
|
||||||
|
local function drill_slot(g, st, d, dm, scope, can_redeploy, n, wait, mk, H, enters)
|
||||||
|
local due = dm.due
|
||||||
|
local reach = dm.reach or nil
|
||||||
|
return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
|
||||||
|
-- (a part of its own: a card that changes keeps the slots that didn't)
|
||||||
|
part(g, "mo:slot:" .. d.toolId, blocks.sig(w, d, dm, scope, can_redeploy, n, wait, mk), p0, x0, y0, function(p)
|
||||||
|
local x, y = 0, 0
|
||||||
|
local slot = ui.rect(p, x, y, w, SLOT_H, "none")
|
||||||
|
local body = ui.rect(slot, 0, 0, w, SLOT_H, due and "ready" or "cyan", due and 0.08 or 0.04)
|
||||||
|
ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil)
|
||||||
|
local iw = w - 18
|
||||||
|
local right
|
||||||
|
local gets = d.here or d.take or d.amount or 0
|
||||||
|
if due then
|
||||||
|
-- (one that gets nothing is claimed all the same: it frees the drill)
|
||||||
|
right = fg.label(body, gets > 0 and ("+" .. ui.money(gets) .. " ready") or "Nothing left",
|
||||||
|
{ font = "sans_semi", px = 12, color = gets > 0 and "ready" or "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 })
|
||||||
|
else
|
||||||
|
right = fg.label(body, ui.dur(d["end"] - H.now(g)), M12, 16, { shrink = 0 })
|
||||||
|
right.done = function(t)
|
||||||
|
H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local second
|
||||||
|
if due then
|
||||||
|
local suffix = n > 1 and ("#" .. n) or ""
|
||||||
|
local kids = {
|
||||||
|
fg.button(body, "Claim", { primary = true, small = true, h = SLOT_BTN_H }, function(b, bn)
|
||||||
|
H.action(g, b, bn, "claim", scope, nil, nil, n > 1 and (act_id("claim", scope) .. suffix) or nil)
|
||||||
|
tooltip.add(b, "Claim every ready drill here")
|
||||||
|
end, { grow = 1, height = SLOT_BTN_H }),
|
||||||
|
}
|
||||||
|
if can_redeploy then
|
||||||
|
local rp = { redeploy = "1" }
|
||||||
|
for k, v in pairs(scope) do
|
||||||
|
rp[k] = v
|
||||||
|
end
|
||||||
|
kids[2] = space(6)
|
||||||
|
kids[3] = fg.button(body, "Redeploy", { small = true, h = SLOT_BTN_H }, function(b, bn)
|
||||||
|
H.action(g, b, bn, "claim", rp, nil, nil, n > 1 and (act_id("claim", rp) .. suffix) or nil)
|
||||||
|
tooltip.add(b, "Claim, then send the drills out again")
|
||||||
|
end, { height = SLOT_BTN_H })
|
||||||
|
end
|
||||||
|
second = row({ height = SLOT_BTN_H, align = "center" }, kids)
|
||||||
|
else
|
||||||
|
local color = d.state == "short" and "ready" or "cyan"
|
||||||
|
local gets
|
||||||
|
if d.state == "none" then
|
||||||
|
gets = fit(body, "Nothing left by its turn", { font = "sans", px = 11, color = "ready" })
|
||||||
|
else
|
||||||
|
local here = ui.money(d.here or d.take or 0)
|
||||||
|
gets = fit(body, "Gets " .. here .. (d.state == "short" and (" of " .. ui.money(d.amount)) or "")
|
||||||
|
.. ((d.reach or 1) > 1 and (d.gets and d.gets ~= d.here) and (" · " .. ui.money(d.gets) .. " in all") or ""),
|
||||||
|
{ font = "sans", px = 11, color = d.state == "short" and "ready" or "muted" })
|
||||||
|
end
|
||||||
|
second = col({ height = SLOT_BTN_H, gap = 4, padding_top = 3 }, {
|
||||||
|
made({ height = 5, shrink = 0 }, function(q, bx, by, bw)
|
||||||
|
bar.timer(g, q, bx, by, bw, 5, { start = d.start, ends = d["end"], ready = false, color = color,
|
||||||
|
none = d.state == "none", key = "slot:" .. d.toolId })
|
||||||
|
end),
|
||||||
|
gets,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
|
||||||
|
row({ height = 16, align = "center" }, {
|
||||||
|
fit(body, dm.name, NAME13),
|
||||||
|
reach and space(5) or nil, reach and fit(body, "· " .. reach, S12) or nil,
|
||||||
|
fg.fill(), space(6), right,
|
||||||
|
}),
|
||||||
|
second,
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(tree, 0, 0), body)
|
||||||
|
if wait and not due then
|
||||||
|
local function spot(params, style)
|
||||||
|
return made(style, function(q, bx, by, bw, bh)
|
||||||
|
test.register("wait:" .. act_id("claim", params):sub(5), ui.rect(q, bx, by, bw, bh, "none"), "wait", nil, nil, true)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
local kids = { spot(scope, { grow = 1, height = SLOT_BTN_H }) }
|
||||||
|
if can_redeploy then
|
||||||
|
local rp = { redeploy = "1" }
|
||||||
|
for k, v in pairs(scope) do
|
||||||
|
rp[k] = v
|
||||||
|
end
|
||||||
|
kids[2] = space(6)
|
||||||
|
kids[3] = spot(rp, { width = fg.button_w(body, "Redeploy", { small = true, h = SLOT_BTN_H }), height = SLOT_BTN_H, shrink = 0 })
|
||||||
|
end
|
||||||
|
fg.build(flex.solve(row({ width = w - 18, height = SLOT_BTN_H }, kids), 9, 27), body)
|
||||||
|
end
|
||||||
|
if d.fate and d.fate ~= "" then
|
||||||
|
tooltip.add(ui.rect(body, 0, 0, iw, 23, "none"), d.fate)
|
||||||
|
end
|
||||||
|
enters[#enters + 1] = function()
|
||||||
|
row_c.enter(g, "drill:" .. d.toolId, body, w, SLOT_H, { base = gui.get_color(body) })
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- One row of the wells table (a well, or the multi-well group): a grid row 52 tall, its
|
-- The drill of a list that finishes next (not done yet), or nil.
|
||||||
-- row node (hover, selection, entrance) in a slot. enters: row entrances to run once the
|
local function next_drill(g, drills)
|
||||||
-- page is built (they veil the row's content).
|
local now, pick = g.h.now(g), nil
|
||||||
local function well_row(g, c, cols, max, moon, well, H, enters)
|
for _, d in ipairs(drills or {}) do
|
||||||
local st, v = g.state, g.view
|
if not d.ready and d["end"] and d["end"] > now and (not pick or d["end"] < pick["end"]) then
|
||||||
local group = well.wide_group or well
|
pick = d
|
||||||
local key = well.id or "wide"
|
|
||||||
local function made_row(parent, x, y, w, h)
|
|
||||||
local slot = ui.rect(parent, x, y, w, h, "none")
|
|
||||||
local r = ui.rect(slot, 0, 0, w, h, "cyan", 0)
|
|
||||||
ui.rect(r, 0, h - 1, w, 1, "line")
|
|
||||||
if not well.wide_group then
|
|
||||||
-- Row click selects the well (bound first: the buttons on it take precedence).
|
|
||||||
row_c.bind(g, r, w, h, { selected = well.id == v.well, id = "well:" .. well.id, label = well.resource,
|
|
||||||
on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
|
|
||||||
g.well_nodes[well.id] = r
|
|
||||||
end
|
end
|
||||||
-- A drill was just deployed here: the row slides in (fx-new).
|
|
||||||
enters[#enters + 1] = function()
|
|
||||||
row_c.enter(g, "well:" .. key, r, w, h, { base = gui.get_color(r) })
|
|
||||||
end
|
|
||||||
return r
|
|
||||||
end
|
end
|
||||||
|
return pick
|
||||||
|
end
|
||||||
|
|
||||||
-- Drill queue: its own drills, and multi-well ones that reach it, oldest first.
|
-- The drills of a group (a well's, or the multi-well ones) as their slots show them:
|
||||||
local drills = group.drills or {}
|
-- { name, due, reach, n (its place among the ready ones, 0: not ready), wait }.
|
||||||
local queue = {}
|
local function drill_models(g, st, drills, H)
|
||||||
for _, d in ipairs(drills) do
|
local now, next_d = H.now(g), next_drill(g, drills)
|
||||||
queue[#queue + 1] = d
|
local out, n = {}, 0
|
||||||
|
for i, d in ipairs(drills or {}) do
|
||||||
|
local due = d.ready or (d["end"] or math.huge) <= now
|
||||||
|
n = n + (due and 1 or 0)
|
||||||
|
out[i] = { name = M.short_name(find(st.tools, d.toolId)), due = due and true or false,
|
||||||
|
reach = reach_short(st, d.reach) or false, n = due and n or 0, wait = d == next_d }
|
||||||
end
|
end
|
||||||
if not well.wide_group and moon.wide then
|
return out
|
||||||
local reach = {}
|
end
|
||||||
for _, id in ipairs(well.wide or {}) do
|
|
||||||
reach[id] = true
|
-- What the idle slot of a well shows (well.redrill > 0).
|
||||||
|
local function idle_view(st, well)
|
||||||
|
local n = well.redrill or 0
|
||||||
|
local names = {}
|
||||||
|
for _, t in ipairs(st.tools or {}) do
|
||||||
|
if #names < 2 and not t.stakeId and predict.memo.last_well[t.id] == well.id then
|
||||||
|
names[#names + 1] = M.short_name(t)
|
||||||
end
|
end
|
||||||
for _, d in ipairs(moon.wide.drills or {}) do
|
end
|
||||||
if reach[d.id] then
|
local who = #names > 0 and (table.concat(names, ", ") .. (n > #names and (" +" .. (n - #names)) or "")) or ui.plural(n, "drill")
|
||||||
queue[#queue + 1] = d
|
-- (no Redrill while the drills on the well take all it has: the server refuses it)
|
||||||
|
local full = well.supply > 0 and (well.remain or 1) <= 1e-9
|
||||||
|
return { n = n, who = who, full = full, can = (st.unlocked.redrill and well.supply > 0 and not full) and true or false }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The slot of the idle drills that last mined the well (well.redrill): how many, and
|
||||||
|
-- Redrill (sends them out again) once unlocked and the well has supply. v: idle_view.
|
||||||
|
local function idle_slot(g, well, v, H)
|
||||||
|
local n = v.n
|
||||||
|
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
|
||||||
|
local body = ui.rect(p, x, y, w, SLOT_H, "cyan", 0.02)
|
||||||
|
ui.outline(body, 0, 0, w, SLOT_H, "line_hi")
|
||||||
|
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
|
||||||
|
row({ height = 16, align = "center" }, {
|
||||||
|
fit(body, v.who, { font = "sans_semi", px = 13, color = "text_2" }), fg.fill(), space(6),
|
||||||
|
fg.label(body, "Idle", { font = "sans_semi", px = 12, color = "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 }),
|
||||||
|
}),
|
||||||
|
v.can and fg.button(body, "Redrill" .. (n > 1 and (" " .. n) or ""), { small = true, variant = "redrill", h = SLOT_BTN_H }, function(b, bn)
|
||||||
|
H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
|
||||||
|
tooltip.add(b, "Send the drills that last mined this well out again")
|
||||||
|
end, { height = SLOT_BTN_H }) or row({ height = SLOT_BTN_H, align = "center" }, {
|
||||||
|
fit(body, well.supply <= 0 and "Last mined here · the well is empty"
|
||||||
|
or (v.full and "Last mined here · the drills on it take the rest" or "Last mined here"), S11),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
fg.build(flex.solve(tree, 0, 0), body)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A well's line under its supply bar: the over-drilled lead in amber, else what the
|
||||||
|
-- drills leave ("45 free after drills"), else nothing (the line is kept).
|
||||||
|
local function well_note(c, well)
|
||||||
|
if well.supply <= 0 then
|
||||||
|
return fit(c, "Tapped out", { font = "sans_semi", px = 12, color = "muted", tracking = 0.1, upper = true })
|
||||||
|
elseif (well.statusText or "") ~= "" then
|
||||||
|
-- (its lead: "Over-drilled", "Fully committed"; the aside has the rest)
|
||||||
|
return fit(c, well.statusText:match("^(.-)%s+—") or well.statusText, { font = "sans", px = 12, color = "ready" })
|
||||||
|
elseif (well.demand or 0) > 0 then
|
||||||
|
return fit(c, ui.money(well.remain or 0) .. " free after drills", { font = "sans", px = 12, color = well.full and "ready" or "muted" })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What a well's card shows (its block's signature with its rect): the well, its deploy
|
||||||
|
-- slot, its drills' slots, its idle slot. slots: how many (card_h).
|
||||||
|
local function card_model(g, st, well, max, groups, H)
|
||||||
|
local dry = well.supply <= 0
|
||||||
|
-- Tapped out once the ready drills are claimed (nothing left, or what is left is the
|
||||||
|
-- other drills' on it, the server's `remain`): Claim only, no Redeploy.
|
||||||
|
local tapped = well.supply - ready.amount(g, well, well.cap) <= 1e-9 or (well.remain or 1) <= 1e-9
|
||||||
|
local m = {
|
||||||
|
well = well, max = max, dry = dry, selected = well.id == g.view.well,
|
||||||
|
redeploy = (not dry and not tapped and st.unlocked.redeploy) and true or false,
|
||||||
|
deploy = not dry and deploy_view(g, st, well, groups) or false,
|
||||||
|
drills = drill_models(g, st, well.drills, H),
|
||||||
|
idle = (well.redrill or 0) > 0 and idle_view(st, well) or false,
|
||||||
|
}
|
||||||
|
m.slots = (dry and 0 or 1) + #m.drills + (m.idle and 1 or 0)
|
||||||
|
local scope = "claim?claim-well=" .. well.id
|
||||||
|
m.marks = marks(g, { scope, scope .. "&redeploy=1", "stake?target=" .. well.id, "redrill?redrill-well=" .. well.id })
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A card's height for its slots (its tree's, below: the head lines, then the slots).
|
||||||
|
local CARD_HEAD = 18 + 7 + 6 + 4 + 16 + 8
|
||||||
|
local function card_h(slots)
|
||||||
|
return CARD_PAD * 2 + CARD_HEAD + (slots > 0 and (slots * SLOT_H + (slots - 1) * SLOT_GAP) or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One well's card, w x h at the origin of c (its node selects the well when clicked).
|
||||||
|
-- m: card_model.
|
||||||
|
local function well_card(g, c, st, moon, m, w, h, H)
|
||||||
|
local well = m.well
|
||||||
|
local dry = m.dry
|
||||||
|
local scope = { ["claim-well"] = well.id }
|
||||||
|
local enters = {}
|
||||||
|
local slots = {}
|
||||||
|
if m.deploy then
|
||||||
|
slots[#slots + 1] = deploy_slot(g, well, m.deploy, m.marks, H)
|
||||||
|
end
|
||||||
|
for i, d in ipairs(well.drills or {}) do
|
||||||
|
local dm = m.drills[i]
|
||||||
|
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
|
||||||
|
end
|
||||||
|
if m.idle then
|
||||||
|
slots[#slots + 1] = idle_slot(g, well, m.idle, H)
|
||||||
|
end
|
||||||
|
local sup = fit(c, ui.money(well.supply), { font = "mono", px = 13, color = dry and "muted" or "text" }, { shrink = 0 })
|
||||||
|
sup.done = function(node) H.tick_num(g, node, "supply:" .. well.id, well.supply) end
|
||||||
|
local list = col({ gap = SLOT_GAP }, slots)
|
||||||
|
local kids = {
|
||||||
|
row({ height = 18, align = "center" }, {
|
||||||
|
made({ width = 8, height = 8, shrink = 0 }, function(q, x, y) ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1) end),
|
||||||
|
space(8), fit(c, well.resource, { font = "sans_semi", px = 15, color = dry and "muted" or "text" }),
|
||||||
|
fg.fill(), space(6), sup,
|
||||||
|
}),
|
||||||
|
space(0, 7),
|
||||||
|
made({ height = 6, shrink = 0 }, function(q, x, y, bw)
|
||||||
|
if dry then
|
||||||
|
ui.rect(q, x, y, bw, 6, "line")
|
||||||
|
else
|
||||||
|
M.supply_bar(q, x, y, bw, well, m.max)
|
||||||
|
end
|
||||||
|
end),
|
||||||
|
space(0, 4),
|
||||||
|
fg.reserve({ height = 16 }, well_note(c, well)),
|
||||||
|
space(0, 8),
|
||||||
|
list,
|
||||||
|
}
|
||||||
|
local tree = col({ padding = CARD_PAD }, kids, { make = function(parent, x, y, cw, ch)
|
||||||
|
local slot = ui.rect(parent, x, y, cw, ch, "none")
|
||||||
|
local card = ui.rect(slot, 0, 0, cw, ch, "cyan", 0)
|
||||||
|
row_c.bind(g, card, cw, ch, { selected = m.selected, id = "well:" .. well.id, label = well.resource,
|
||||||
|
on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
|
||||||
|
ui.outline(card, 0, 0, cw, ch, m.selected and "cyan" or "line_hi", m.selected and 0.9 or nil)
|
||||||
|
blocks.put(g, "well_nodes", well.id, card)
|
||||||
|
return card
|
||||||
|
end })
|
||||||
|
fg.build(flex.solve(tree, 0, 0, w, h), c)
|
||||||
|
if math.abs(list.y + list.h + CARD_PAD - card_h(m.slots)) > 0.5 then
|
||||||
|
print("ERROR:MM: moons: a well card's height is " .. (list.y + list.h + CARD_PAD) .. ", card_h says " .. card_h(m.slots))
|
||||||
|
end
|
||||||
|
for _, f in ipairs(enters) do
|
||||||
|
f()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The multi-well drills (moon.wide: reach > 1, claimed together with claim-wide): what its
|
||||||
|
-- card shows.
|
||||||
|
local function wide_model(g, st, moon, H)
|
||||||
|
local wide = moon.wide
|
||||||
|
-- (the moon tapped out once these are claimed: no Redeploy)
|
||||||
|
local left = 0
|
||||||
|
for _, w in ipairs(moon.wells or {}) do
|
||||||
|
left = left + w.supply
|
||||||
|
end
|
||||||
|
local tapped = left - ready.amount(g, wide, wide.cap) <= 1e-9
|
||||||
|
local scope = "claim?claim-wide=" .. moon.id
|
||||||
|
return { wide = wide, redeploy = (not tapped and st.unlocked.redeploy) and true or false,
|
||||||
|
drills = drill_models(g, st, wide.drills, H), marks = marks(g, { scope, scope .. "&redeploy=1" }) }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Its card across the grid (inner wide, at the origin of c), its slots in the cards'
|
||||||
|
-- columns. Returns its height.
|
||||||
|
local function wide_card(g, c, st, moon, m, inner, H)
|
||||||
|
local wide = m.wide
|
||||||
|
local scope = { ["claim-wide"] = moon.id }
|
||||||
|
local enters = {}
|
||||||
|
local slots = {}
|
||||||
|
for i, d in ipairs(wide.drills or {}) do
|
||||||
|
local dm = m.drills[i]
|
||||||
|
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
|
||||||
|
end
|
||||||
|
local sw = inner - CARD_PAD * 2
|
||||||
|
local tree = col({ width = inner, padding = CARD_PAD }, {
|
||||||
|
row({ height = 18, align = "center" }, {
|
||||||
|
fg.label(c, "Multi-well", { font = "sans_semi", px = 15, color = "text" }, 18), space(10),
|
||||||
|
fit(c, ui.money(wide.cap or 0) .. " left on the moon", S12), fg.fill(),
|
||||||
|
}),
|
||||||
|
space(0, 10),
|
||||||
|
flex.grid(sw, CARD_MIN - CARD_PAD * 2, SLOT_GAP, slots),
|
||||||
|
}, { make = function(parent, x, y, w, h)
|
||||||
|
local card = ui.rect(parent, x, y, w, h, "cyan", 0)
|
||||||
|
ui.outline(card, 0, 0, w, h, "line_hi")
|
||||||
|
return card
|
||||||
|
end })
|
||||||
|
fg.build(flex.solve(tree, 0, 0), c)
|
||||||
|
for _, f in ipairs(enters) do
|
||||||
|
f()
|
||||||
|
end
|
||||||
|
return tree.h
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What the tray of idle drills shows: a tile per group (the well it deploys to: the
|
||||||
|
-- selected one, else the first it has room on).
|
||||||
|
local function tray_model(g, st, groups)
|
||||||
|
local moon = st.moon
|
||||||
|
local function target(t)
|
||||||
|
local wells = moon and moon.wells or {}
|
||||||
|
for _, w in ipairs(wells) do
|
||||||
|
if w.id == g.view.well then
|
||||||
|
return w
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, w in ipairs(wells) do
|
||||||
|
if w.supply > 0 and gets_here(st, w, t) > EPS then
|
||||||
|
return w
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
table.sort(queue, function(a, b) return (a.start or 0) < (b.start or 0) end)
|
|
||||||
end
|
end
|
||||||
local fates = {}
|
local tiles, keys = {}, {}
|
||||||
for _, d in ipairs(queue) do
|
for i, grp in ipairs(groups) do
|
||||||
fates[#fates + 1] = d.fate
|
local t = grp.tool
|
||||||
|
local well = target(t)
|
||||||
|
local here = well and gets_here(st, well, t) or 0
|
||||||
|
tiles[i] = { key = grp.key, name = M.short_name(t), count = grp.count, tool = t.id, tname = t.name or false, image = t.image or false,
|
||||||
|
gets = "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach_short(st, t.reach) and (" · " .. reach_short(st, t.reach)) or ""),
|
||||||
|
well = well and well.id or false, resource = well and well.resource or false,
|
||||||
|
ok = (well ~= nil and well.supply > 0 and here > EPS) and true or false }
|
||||||
|
if well then
|
||||||
|
keys[#keys + 1] = "stake?target=" .. well.id .. "&tool=" .. t.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
local label = (group.label or "") ~= "" and group.label or drill_label(st, drills) -- (the server's DrillLabel)
|
return { tiles = tiles, store = st.unlocked.store and true or false, moon = g.view.moon, marks = marks(g, keys) }
|
||||||
|
end
|
||||||
|
|
||||||
-- Name and supply cells.
|
-- The tray (inner wide, at the origin of c): a tile per group (picture, short name,
|
||||||
local ML = { font = "mono", px = 12, color = "muted" }
|
-- ×count, what a cycle gets); a tile deploys one of them. m: tray_model. Returns its
|
||||||
local name_cell, supply_cell
|
-- height.
|
||||||
if well.wide_group then
|
local function tray(g, c, m, inner, H)
|
||||||
name_cell = row({ padding_left = 26, align = "center" }, { fg.label(c, "Multi-well", "cap", 16) })
|
local head = row({ height = 16, align = "center" }, {
|
||||||
-- "X left on the moon", wrapping onto a second line when the column is narrow.
|
fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0),
|
||||||
supply_cell = col({ padding_right = 8, justify = "center" }, {
|
#m.tiles > 0 and fit(c, "Click one to deploy it to the selected well", "small") or nil,
|
||||||
row({ wrap = true, column_gap = 5, row_gap = 1 }, {
|
})
|
||||||
fg.label(c, ui.money(group.cap), ML, 16),
|
local tree
|
||||||
fg.label(c, "left on the moon", { font = "sans", px = 12, color = "muted" }, 16, { fit = true, shrink = 1 }),
|
if #m.tiles == 0 then
|
||||||
|
tree = col({ width = inner }, {
|
||||||
|
head, space(0, 8),
|
||||||
|
row({ height = 16, align = "center" }, {
|
||||||
|
fg.label(c, "No idle drills.", "small", 16),
|
||||||
|
m.store and space(5) or nil,
|
||||||
|
m.store and made({ width = 160, height = 16, shrink = 0 }, function(p, x, y)
|
||||||
|
H.text_link(g, p, "Buy one in the store", { font = "sans", px = 13 }, x, y + 8, { section = "store", moon = m.moon })
|
||||||
|
end) or nil,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
else
|
else
|
||||||
local dry = well.supply <= 0
|
local tiles = {}
|
||||||
name_cell = row({ padding_left = 26, align = "center" }, {
|
for _, tv in ipairs(m.tiles) do
|
||||||
made({ position = "absolute", left = 10, top = 22, width = 8, height = 8 }, function(q, x, y)
|
local picked = false
|
||||||
ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1)
|
tiles[#tiles + 1] = made({ height = TILE_H }, function(p0, x0, y0, w)
|
||||||
end),
|
part(g, "mo:tile:" .. tv.key, blocks.sig(w, tv, m.marks), p0, x0, y0, function(p)
|
||||||
fg.label(c, well.resource, { font = "sans", px = 15, color = dry and "muted" or "text" }, 16),
|
local x, y = 0, 0
|
||||||
})
|
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = TILE_H, disabled = not tv.ok })
|
||||||
if dry then
|
local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg")
|
||||||
supply_cell = row({ align = "center" }, {
|
if tv.image then
|
||||||
fg.label(c, "Tapped out", { font = "sans_semi", px = 12, color = "muted", tracking = 0.1, upper = true }, 16),
|
images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), tv.image)
|
||||||
})
|
end
|
||||||
else
|
local tt = col({ width = w, height = TILE_H, padding = { 8, 8, 8, TILE_H }, gap = 4 }, {
|
||||||
local sup = fg.label(c, ui.money(well.supply), ML, 16)
|
row({ height = 16, align = "center" }, {
|
||||||
sup.done = function(n) H.tick_num(g, n, "supply:" .. well.id, well.supply) end
|
fit(b, tv.name, NAME13), fg.fill(), space(4),
|
||||||
supply_cell = col({ padding = { 20, 14, 0, 0 } }, {
|
fg.label(b, "×" .. tv.count, { font = "mono", px = 12, color = picked and "cyan" or "text_2" }, 16, { shrink = 0 }),
|
||||||
made({ height = 6 }, function(q, x, y, w) M.supply_bar(q, x, y, w, well, max) end),
|
}),
|
||||||
space(0, 2), sup,
|
fit(b, tv.gets, { font = "mono", px = 11, color = "muted" }),
|
||||||
})
|
})
|
||||||
|
fg.build(flex.solve(tt, 0, 0), b)
|
||||||
|
if picked then
|
||||||
|
ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9)
|
||||||
|
end
|
||||||
|
if tv.ok then
|
||||||
|
tooltip.add(b, "Deploy " .. (tv.tname or "a drill") .. " to the " .. tv.resource .. " well")
|
||||||
|
H.action(g, b, bn, "stake", { tool = tv.tool, target = tv.well })
|
||||||
|
else
|
||||||
|
tooltip.add(b, (tv.tname or "Drill") .. (tv.well and (" · no room on the " .. tv.resource .. " well") or " · no room on this moon"))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
tree = col({ width = inner }, { head, space(0, 8), flex.grid(inner, TILE_MIN, 8, tiles) })
|
||||||
end
|
end
|
||||||
|
fg.build(flex.solve(tree, 0, 0), c)
|
||||||
-- Status (right): Claim (ready), Claim countdown (mining), Redrill (idle tools that
|
return tree.h
|
||||||
-- mined it), then Manage (View when tapped out), like the web's row actions.
|
|
||||||
-- (the amount moves on as more of its drills finish on our clock: core/ready)
|
|
||||||
local scope = well.wide_group and { ["claim-wide"] = moon.id } or { ["claim-well"] = well.id }
|
|
||||||
local amount = ready.amount(g, group, well.wide_group and group.cap or well.supply)
|
|
||||||
local status = {}
|
|
||||||
if group.ready > 0 then
|
|
||||||
status[#status + 1] = fg.button(c, "Claim " .. ui.money(amount), { primary = true, small = true }, function(b, bn)
|
|
||||||
H.action(g, b, bn, "claim", scope)
|
|
||||||
end)
|
|
||||||
elseif group.nextEnd then
|
|
||||||
local wopts = { small = true, right = true, key = "wait:" .. key, params = scope, moon = moon.id,
|
|
||||||
done = amount > 0 and ("Claim " .. ui.money(amount)) or nil }
|
|
||||||
local bw = H.claim_wait_w(g, c, group.nextEnd, wopts)
|
|
||||||
status[#status + 1] = made({ width = bw, height = S.button_sm_h, shrink = 0 }, function(q, x, y, w)
|
|
||||||
H.claim_wait(g, q, x + w, y, group.nextEnd, wopts)
|
|
||||||
end)
|
|
||||||
elseif not well.wide_group and (well.redrill or 0) > 0 and well.supply > 0 and st.unlocked.redrill then
|
|
||||||
status[#status + 1] = fg.button(c, "Redrill", { small = true, variant = "redrill" }, function(b, bn)
|
|
||||||
H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
if not well.wide_group then
|
|
||||||
local to = { section = "moons", moon = moon.id, well = well.id }
|
|
||||||
status[#status + 1] = fg.button(c, well.supply <= 0 and "View" or "Manage", { small = true }, function(b, bn)
|
|
||||||
button.bind(g, b, bn, { id = "manage:" .. well.id, on_click = function() H.navigate(g, to) end })
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
return box({ columns = cols, rows = { S.well_row_h }, padding_right = 8 }, {
|
|
||||||
name_cell, supply_cell,
|
|
||||||
drill_cell(g, queue, fates, label, key, #status > 0 and 14 or 8),
|
|
||||||
row({ gap = 6, align = "center" }, status),
|
|
||||||
}, { make = made_row })
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
local function rating_chip(c, r)
|
local function rating_chip(c, r)
|
||||||
-- The web's rating chip ("★3" in the tier's color; its tooltip the label and percentile).
|
-- The web's rating chip ("★3" in the tier's color; its tooltip the label and percentile).
|
||||||
local edge, alpha, fill = "line_hi", 1, nil
|
local edge, alpha, fill = "line_hi", 1, nil
|
||||||
@@ -411,13 +825,10 @@ local function rating_chip(c, r)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The moon: heading (id, name, class and rating, description; Claim at the right), the
|
|
||||||
-- wells table (min_h tall at least; the moon's sale is in the aside). Returns its
|
|
||||||
-- bottom.
|
|
||||||
local function moon_view(g, c, px, py, inner, min_h, H)
|
|
||||||
local st = g.state
|
|
||||||
local moon = st.moon
|
|
||||||
|
|
||||||
|
-- The heading (id, name, class and rating, description; Claim at the right), inner wide
|
||||||
|
-- at the origin of c, 80 tall at least. Returns its height.
|
||||||
|
local function heading(g, c, moon, n_ready, inner, H)
|
||||||
-- The class line ("<type> class"), but not for moons named after their type (from
|
-- The class line ("<type> class"), but not for moons named after their type (from
|
||||||
-- before moon names), like the web.
|
-- before moon names), like the web.
|
||||||
local has_class = moon.kind and moon.kind ~= "" and moon.kind ~= moon.name
|
local has_class = moon.kind and moon.kind ~= "" and moon.kind ~= moon.name
|
||||||
@@ -441,7 +852,6 @@ local function moon_view(g, c, px, py, inner, min_h, H)
|
|||||||
head[#head + 1] = space(0, 4)
|
head[#head + 1] = space(0, 4)
|
||||||
end
|
end
|
||||||
-- Claim, at the top right (out of the flow: the heading's text keeps clear of it).
|
-- Claim, at the top right (out of the flow: the heading's text keeps clear of it).
|
||||||
local n_ready = ready.drills(g, moon.id)
|
|
||||||
if moon.ready > 0 then
|
if moon.ready > 0 then
|
||||||
head[#head + 1] = made({ position = "absolute", top = 26, right = 0 }, function(p, x, y)
|
head[#head + 1] = made({ position = "absolute", top = 26, right = 0 }, function(p, x, y)
|
||||||
local b, nodes, bw = ui.button(p, "Claim " .. ui.plural(n_ready, "drill"), x, y, { primary = true })
|
local b, nodes, bw = ui.button(p, "Claim " .. ui.plural(n_ready, "drill"), x, y, { primary = true })
|
||||||
@@ -455,44 +865,81 @@ local function moon_view(g, c, px, py, inner, min_h, H)
|
|||||||
done = n_ready > 0 and ("Claim " .. ui.plural(n_ready, "drill")) or nil })
|
done = n_ready > 0 and ("Claim " .. ui.plural(n_ready, "drill")) or nil })
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
local tree = col({ width = inner, min_height = 80, align = "start" }, head)
|
||||||
-- Wells table: the header, an edge line, a row per well and the multi-well group.
|
fg.build(flex.solve(tree, 0, 0), c)
|
||||||
local max = M.supply_max(st, moon)
|
return tree.h
|
||||||
local cols = table_cols(inner)
|
|
||||||
local enters = {}
|
|
||||||
local tbl = {
|
|
||||||
box({ columns = cols, height = 16, align = "start" }, {
|
|
||||||
fg.label(c, "Well", "cap", 12), fg.label(c, "Supply left", "cap", 12), fg.label(c, "Drill", "cap", 12),
|
|
||||||
fg.label(c, "Status", "cap", 12, { justify_self = "end" }),
|
|
||||||
}),
|
|
||||||
made({ position = "absolute", top = 16, left = 0, right = 0, height = 1 }, function(p, x, y, w)
|
|
||||||
ui.rect(p, x, y, w, 1, "edge")
|
|
||||||
end),
|
|
||||||
}
|
|
||||||
for _, well in ipairs(moon.wells) do
|
|
||||||
tbl[#tbl + 1] = well_row(g, c, cols, max, moon, well, H, enters)
|
|
||||||
end
|
|
||||||
if moon.wide then
|
|
||||||
tbl[#tbl + 1] = well_row(g, c, cols, max, moon, { wide_group = moon.wide }, H, enters)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- (selling the moon is in the aside, under the well: aside.lua)
|
|
||||||
local page = col({ width = inner, min_height = min_h }, {
|
|
||||||
col({ min_height = 80, align = "start" }, head),
|
|
||||||
space(0, 14),
|
|
||||||
col({}, tbl),
|
|
||||||
})
|
|
||||||
fg.build(flex.solve(page, px, py), c)
|
|
||||||
for _, f in ipairs(enters) do
|
|
||||||
f()
|
|
||||||
end
|
|
||||||
return page.y + page.h
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The moon: heading, the well cards, the multi-well card, the tray of idle drills (selling
|
||||||
|
-- the moon is in the aside: aside.lua), each a kept part, top to bottom from (px, py) of
|
||||||
|
-- c. min_h tall at least. Returns its bottom.
|
||||||
|
local function moon_view(g, c, px, py, inner, min_h, H)
|
||||||
|
local st = g.state
|
||||||
|
local moon = st.moon
|
||||||
|
local now = H.now(g)
|
||||||
|
local y = py
|
||||||
|
|
||||||
|
local n_ready = ready.drills(g, moon.id)
|
||||||
|
local claim = "claim?claim-moon=" .. moon.id
|
||||||
|
y = y + part(g, "mo:head", blocks.sig(inner, moon.id, moon.name, moon.kind or false, moon.grade or false,
|
||||||
|
moon.rating or false, moon.description or false, moon.ready, moon.nextEnd or false,
|
||||||
|
(moon.nextEnd or math.huge) <= now, n_ready, marks(g, { claim })), c, px, y, function(root)
|
||||||
|
return heading(g, root, moon, n_ready, inner, H)
|
||||||
|
end)
|
||||||
|
y = y + 14
|
||||||
|
|
||||||
|
-- The cards: a well each (as many columns as CARD_MIN allows; a row as tall as its
|
||||||
|
-- tallest card), the multi-well card across them, then the idle drills.
|
||||||
|
local max = M.supply_max(st, moon)
|
||||||
|
local groups = M.idle_groups(st)
|
||||||
|
local pick_ok = false
|
||||||
|
for _, grp in ipairs(groups) do
|
||||||
|
pick_ok = pick_ok or grp.key == g.deploy_pick
|
||||||
|
end
|
||||||
|
if not pick_ok then
|
||||||
|
g.deploy_pick = nil -- (used up)
|
||||||
|
end
|
||||||
|
local cols, cw = flex.columns(inner, CARD_MIN, CARD_GAP)
|
||||||
|
local wells = moon.wells or {}
|
||||||
|
local models = {}
|
||||||
|
for i, well in ipairs(wells) do
|
||||||
|
models[i] = card_model(g, st, well, max, groups, H)
|
||||||
|
end
|
||||||
|
for first = 1, #wells, cols do
|
||||||
|
local last = math.min(#wells, first + cols - 1)
|
||||||
|
local rh = 0
|
||||||
|
for i = first, last do
|
||||||
|
rh = math.max(rh, card_h(models[i].slots))
|
||||||
|
end
|
||||||
|
for i = first, last do
|
||||||
|
local m = models[i]
|
||||||
|
part(g, "mo:well:" .. wells[i].id, blocks.sig(cw, rh, m), c, px + (i - first) * (cw + CARD_GAP), y, function(root)
|
||||||
|
well_card(g, root, st, moon, m, cw, rh, H)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
y = y + rh + (last < #wells and CARD_GAP or 0)
|
||||||
|
end
|
||||||
|
if moon.wide and #(moon.wide.drills or {}) > 0 then
|
||||||
|
y = y + CARD_GAP
|
||||||
|
local m = wide_model(g, st, moon, H)
|
||||||
|
y = y + part(g, "mo:wide", blocks.sig(inner, moon.id, m), c, px, y, function(root)
|
||||||
|
return wide_card(g, root, st, moon, m, inner, H)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
y = y + 20
|
||||||
|
local tm = tray_model(g, st, groups)
|
||||||
|
y = y + part(g, "mo:tray", blocks.sig(inner, tm), c, px, y, function(root)
|
||||||
|
return tray(g, root, tm, inner, H)
|
||||||
|
end)
|
||||||
|
return math.max(y, py + min_h)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
-- Section contract (AGENTS.md, "Game screen"): root is the main panel, (px, py) its
|
-- Section contract (AGENTS.md, "Game screen"): root is the main panel, (px, py) its
|
||||||
-- padding, w x h its size.
|
-- padding, w x h its size.
|
||||||
function M.build(g, root, px, py, w, h, H)
|
function M.build(g, root, px, py, w, h, H)
|
||||||
local inner = math.min(w - px * 2, S.content_max)
|
-- (as wide as a card grid gets: a moon's well cards, 5 across where there is room)
|
||||||
|
local inner = math.min(w - px * 2, S.cards_max)
|
||||||
px = px + math.floor((w - px * 2 - inner) / 2) -- capped content is centered
|
px = px + math.floor((w - px * 2 - inner) / 2) -- capped content is centered
|
||||||
local st = g.state
|
local st = g.state
|
||||||
M.track_drills(g)
|
M.track_drills(g)
|
||||||
|
|||||||
@@ -488,9 +488,9 @@ end
|
|||||||
-- "Owned N" on the recipe cards: fx counters ("refined:<item>", bound after the build), so
|
-- "Owned N" on the recipe cards: fx counters ("refined:<item>", bound after the build), so
|
||||||
-- a collect's motes land there and the number counts up. Laid out at the final amount.
|
-- a collect's motes land there and the number counts up. Laid out at the final amount.
|
||||||
local counted = {}
|
local counted = {}
|
||||||
local function own_label(c, r)
|
local function own_label(c, r, color)
|
||||||
local l = fg.label(c, ui.money(r.owned), CAP_NUM, 24)
|
local l = fg.label(c, ui.money(r.owned or 0), { font = "mono_semi", px = 24, color = color }, 26)
|
||||||
counted[#counted + 1] = { key = "refined:" .. r.item, node = l.node, amount = r.owned }
|
counted[#counted + 1] = { key = "refined:" .. r.item, node = l.node, amount = r.owned or 0 }
|
||||||
return l
|
return l
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -501,8 +501,9 @@ local function recipe_card(self, H, c, st, rf, r)
|
|||||||
local why = (r.whyText or "") ~= "" and r.whyText or WHY[r.why] or r.why
|
local why = (r.whyText or "") ~= "" and r.whyText or WHY[r.why] or r.why
|
||||||
local owned = (r.owned or 0) > 0
|
local owned = (r.owned or 0) > 0
|
||||||
|
|
||||||
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N ·
|
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N" over
|
||||||
-- owned X" over the name.
|
-- the name, and on the right what you own, big, in the item's color (a collect's motes
|
||||||
|
-- fly into it and it counts up), shown at 0 too.
|
||||||
local glyph = { style = { width = 36, height = 36, shrink = 0 }, make = function(p, x, y)
|
local glyph = { style = { width = 36, height = 36, shrink = 0 }, make = function(p, x, y)
|
||||||
local g = ui.rect(p, x, y, 36, 36, color, 0.85)
|
local g = ui.rect(p, x, y, 36, 36, color, 0.85)
|
||||||
gui.play_flipbook(g, "pentagon")
|
gui.play_flipbook(g, "pentagon")
|
||||||
@@ -510,13 +511,14 @@ local function recipe_card(self, H, c, st, rf, r)
|
|||||||
end }
|
end }
|
||||||
local head = row({ height = 43.3, gap = 12, align = "start" }, {
|
local head = row({ height = 43.3, gap = 12, align = "start" }, {
|
||||||
col({ padding_top = 3.6 }, { glyph }),
|
col({ padding_top = 3.6 }, { glyph }),
|
||||||
col({ grow = 1, align = "start" }, {
|
col({ grow = 1, shrink = 1, align = "start" }, {
|
||||||
row({}, {
|
fg.label(c, "Tier " .. r.tier, "cap", 24),
|
||||||
fg.label(c, "Tier " .. r.tier .. (owned and " · owned " or ""), "cap", 24),
|
|
||||||
owned and own_label(c, r) or nil,
|
|
||||||
}),
|
|
||||||
fg.label(c, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.14, upper = true }, 17.8, { fit = true }),
|
fg.label(c, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.14, upper = true }, 17.8, { fit = true }),
|
||||||
}),
|
}),
|
||||||
|
col({ align = "end", shrink = 0 }, {
|
||||||
|
fg.label(c, "Owned", "cap", 14),
|
||||||
|
own_label(c, r, owned and color or "muted"),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
-- Inputs per unit: need and name, what you hold on the right (red when short).
|
-- Inputs per unit: need and name, what you hold on the right (red when short).
|
||||||
@@ -558,32 +560,34 @@ local function recipe_card(self, H, c, st, rf, r)
|
|||||||
return n
|
return n
|
||||||
end })
|
end })
|
||||||
|
|
||||||
-- The form: quantity and Max on a row, Refine the card's width under them (beside the
|
-- The form: the quantity and Refine (that many) on a row, Max (as many as you can, the
|
||||||
-- quantity and "Max 250" it was cramped).
|
-- usual click) the card's width under them, primary.
|
||||||
local qty, input = qty_field(self, r, off, rf.maxBatch)
|
local qty, input = qty_field(self, r, off, rf.maxBatch)
|
||||||
qty.style.width, qty.style.grow = nil, 1
|
qty.style.width, qty.style.grow = nil, 1
|
||||||
local refine
|
local refine
|
||||||
local refine_btn = btn("Refine", BTN_SM, { primary = true, disabled = off }, function(b, nodes)
|
local refine_btn = btn("Refine", BTN_SM, { disabled = off }, function(b, nodes)
|
||||||
if off then
|
if off then
|
||||||
return tooltip.add(b, why)
|
return tooltip.add(b, why)
|
||||||
end
|
end
|
||||||
refine = H.action(self, b, nodes, "refine", function()
|
refine = H.action(self, b, nodes, "refine", function()
|
||||||
local q = self.refine_qty and self.refine_qty[r.item] or "1"
|
local q = self.refine_qty and self.refine_qty[r.item] or "1"
|
||||||
return { recipe = r.item, qty = (q ~= "" and q or "1") }
|
return { recipe = r.item, qty = (q ~= "" and q or "1") }
|
||||||
end)
|
end, nil, nil, "act:refine?recipe=" .. r.item)
|
||||||
-- Enter in the quantity refines (the web's form).
|
tooltip.add(b, "Refine the number in the box")
|
||||||
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
|
-- Enter in the quantity refines (the web's form).
|
||||||
end, { grow = 1 })
|
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
|
||||||
local form = col({ shrink = 0 }, { row({ height = BTN_SM, gap = 6, shrink = 0 }, {
|
end)
|
||||||
qty,
|
local max_btn = btn(off and "Max" or ("Max · " .. ui.money(r.maxQty)), BTN_SM, { primary = true, disabled = off }, function(b, nodes)
|
||||||
btn(off and "Max" or ("Max " .. r.maxQty), BTN_SM, { disabled = off }, function(b, nodes)
|
if off then
|
||||||
if off then
|
return tooltip.add(b, why)
|
||||||
return tooltip.add(b, why)
|
end
|
||||||
end
|
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
|
||||||
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
|
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
|
||||||
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
|
end, { grow = 1 })
|
||||||
end),
|
local form = col({ shrink = 0 }, {
|
||||||
}), space(0, 6), row({ height = BTN_SM, shrink = 0 }, { refine_btn }) })
|
row({ height = BTN_SM, gap = 6, shrink = 0 }, { qty, refine_btn }),
|
||||||
|
space(0, 6), row({ height = BTN_SM, shrink = 0 }, { max_btn }),
|
||||||
|
})
|
||||||
|
|
||||||
local desc = catalog_desc(st, r.item)
|
local desc = catalog_desc(st, r.item)
|
||||||
return col({ padding = 17 }, {
|
return col({ padding = 17 }, {
|
||||||
|
|||||||
@@ -147,6 +147,25 @@ function M.build_teaser(g, root, px, py, inner)
|
|||||||
ui.rect(p, x + 16, y, w - 32, 1, "edge")
|
ui.rect(p, x + 16, y, w - 32, 1, "edge")
|
||||||
return nil
|
return nil
|
||||||
end } or nil)
|
end } or nil)
|
||||||
|
-- Moon packs (once the store is open): several at once, cheaper and better, a button
|
||||||
|
-- each under the grade's facts.
|
||||||
|
if #(e.packs or {}) > 0 then
|
||||||
|
local packs = {}
|
||||||
|
for _, pk in ipairs(e.packs) do
|
||||||
|
local label = pk.count .. " moons · " .. theme.currency .. ui.money(pk.price)
|
||||||
|
packs[#packs + 1] = fg.button(root, label, { small = true, disabled = not pk.canAfford }, function(b, nodes)
|
||||||
|
local save = math.floor(pk.discount * 100 + 0.5)
|
||||||
|
tooltip.add(b, pk.canAfford and (pk.count .. " " .. e.grade .. " moons for " .. theme.currency .. ui.money(pk.price)
|
||||||
|
.. " (" .. save .. "% off), each a little richer than a single one")
|
||||||
|
or ("Need " .. theme.currency .. ui.money(pk.price - st.credits) .. " more"))
|
||||||
|
if pk.canAfford then
|
||||||
|
g.h.action(g, b, nodes, "buy-moon", { grade = e.grade, count = pk.count, include = "new-moon" },
|
||||||
|
function() g.nm_grade = nil end, nil, "newmoon:pack:" .. e.grade .. ":" .. pk.count)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
rows[#rows + 1] = row({ height = 40, align = "start", padding = { 0, 16, 10, 56 }, gap = 8 }, packs)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
local picker = panel_box({ width = inner, padding = { 14, 0, 6, 0 } }, {
|
local picker = panel_box({ width = inner, padding = { 14, 0, 6, 0 } }, {
|
||||||
row({ padding = { 0, 16, 0, 16 } }, { fg.label(root, "Moon grades", "cap", 12) }),
|
row({ padding = { 0, 16, 0, 16 } }, { fg.label(root, "Moon grades", "cap", 12) }),
|
||||||
@@ -516,6 +535,9 @@ function M:bind()
|
|||||||
if r.card then
|
if r.card then
|
||||||
if g.autoplay then
|
if g.autoplay then
|
||||||
g.autoplay:register("reveal-close", {}, function() self:close() end)
|
g.autoplay:register("reveal-close", {}, function() self:close() end)
|
||||||
|
if r.another_btn and r.another_ok then
|
||||||
|
g.autoplay:register("reveal-buy", {}, function() self:buy_another() end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
if r.another_btn and not r.another_ok then
|
if r.another_btn and not r.another_ok then
|
||||||
tooltip.add(r.another_btn, r.another_why)
|
tooltip.add(r.another_btn, r.another_why)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
-- gear, a text button per setting or action with its icon on the right: How to play (the
|
-- gear, a text button per setting or action with its icon on the right: How to play (the
|
||||||
-- Help view), the UI scale (window pixels per layout unit, times the display's own scale;
|
-- Help view), the UI scale (window pixels per layout unit, times the display's own scale;
|
||||||
-- the browser's zoom), fullscreen, sound, theme, reduced motion, Save code and Restore from
|
-- the browser's zoom), fullscreen, sound, theme, reduced motion, Save code and Restore from
|
||||||
-- code (savecode.lua), and Grant for admins.
|
-- code (savecode.lua), Grant for admins, and Moon lab (the admin area, admin.lua) for
|
||||||
|
-- admins and in debug builds.
|
||||||
-- Saved settings live in the settings file (modules/screen, sfx, theme, anim). Keys: Ctrl
|
-- Saved settings live in the settings file (modules/screen, sfx, theme, anim). Keys: Ctrl
|
||||||
-- + / Ctrl - / Ctrl 0, F11 (core/keys). A click outside or Esc closes it.
|
-- + / Ctrl - / Ctrl 0, F11 (core/keys). A click outside or Esc closes it.
|
||||||
|
|
||||||
@@ -73,6 +74,10 @@ local ICONS = {
|
|||||||
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
|
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
|
||||||
ui.text(p, "›", { font = "sym", px = 18, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
ui.text(p, "›", { font = "sym", px = 18, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
||||||
end,
|
end,
|
||||||
|
lab = function(p, x, y)
|
||||||
|
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "cyan")
|
||||||
|
gui.play_flipbook(icon, "moon")
|
||||||
|
end,
|
||||||
grant = function(p, x, y)
|
grant = function(p, x, y)
|
||||||
ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
||||||
end,
|
end,
|
||||||
@@ -98,7 +103,9 @@ end
|
|||||||
function M.open(g, right, top)
|
function M.open(g, right, top)
|
||||||
local st = g.state
|
local st = g.state
|
||||||
local help = st and st.unlocked and st.unlocked.help
|
local help = st and st.unlocked and st.unlocked.help
|
||||||
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0)
|
local admin = require("screens.game.admin")
|
||||||
|
local lab = st and admin.allowed(g)
|
||||||
|
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0) + (lab and 1 or 0)
|
||||||
local h = PAD * 2 + rows * ROW_H + 8
|
local h = PAD * 2 + rows * ROW_H + 8
|
||||||
g.dialog:open({
|
g.dialog:open({
|
||||||
w = W,
|
w = W,
|
||||||
@@ -171,6 +178,12 @@ function M.open(g, right, top)
|
|||||||
item(d, box, y, "Grant (G)", "grant", "grant", close_then(function()
|
item(d, box, y, "Grant (G)", "grant", "grant", close_then(function()
|
||||||
require("screens.game.grant").open(g)
|
require("screens.game.grant").open(g)
|
||||||
end))
|
end))
|
||||||
|
y = y + ROW_H
|
||||||
|
end
|
||||||
|
if lab then
|
||||||
|
item(d, box, y, "Moon lab", "lab", "menu:moon-lab", close_then(function()
|
||||||
|
admin.open(g, "moonlab")
|
||||||
|
end), { disabled = g.reveal:active() })
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- The Store section (state.store): tool cards and attachment cards with their images,
|
-- The Store section (state.store): tool cards with their images,
|
||||||
-- prices, what they'd earn and how many you own. Buying a tool opens the buy modal
|
-- prices, what they'd earn and how many you own. Buying a tool opens the buy modal
|
||||||
-- (the response's buyModal: up to 3 wells to deploy it on).
|
-- (the response's buyModal: up to 3 wells to deploy it on).
|
||||||
|
|
||||||
@@ -185,26 +185,6 @@ local function tool_card(self, H, st, c, t)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function gear_card(self, H, st, c, a)
|
|
||||||
c:image(a.image)
|
|
||||||
c:title(a.name)
|
|
||||||
c:text(a.effectText or cards.effect(a.effect, "attachment"), { font = "mono", px = 14, color = "violet_hi" }, 22)
|
|
||||||
c:para(a.description, { font = "sans", px = 13, color = "text_2" })
|
|
||||||
c:gap(4)
|
|
||||||
local short = not a.canAfford
|
|
||||||
c:footer(30 + S.button_sm_h + 18, function(f, bw) -- (the need line's place is kept: buy_footer)
|
|
||||||
M.buy_footer(f, bw, { { "Spare", a.spare }, { "Equipped", a.equipped } }, a.price, short,
|
|
||||||
(a.whyText or "") ~= "" and a.whyText or ("Need " .. theme.currency .. ui.money(a.price - st.credits) .. " more"),
|
|
||||||
function(b, nodes)
|
|
||||||
if short then
|
|
||||||
tooltip.add(b, a.whyText)
|
|
||||||
else
|
|
||||||
H.action(self, b, nodes, "buy-attachment", { item = a.name })
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.build(self, root, px, py, w, h, H)
|
function M.build(self, root, px, py, w, h, H)
|
||||||
local st = self.state
|
local st = self.state
|
||||||
local sto = st.store
|
local sto = st.store
|
||||||
@@ -217,11 +197,10 @@ function M.build(self, root, px, py, w, h, H)
|
|||||||
local function cap(label, before)
|
local function cap(label, before)
|
||||||
return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) })
|
return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) })
|
||||||
end
|
end
|
||||||
local attachments = #(sto.attachments or {}) > 0
|
|
||||||
y = fg.flow(c, x, y, inner, {
|
y = fg.flow(c, x, y, inner, {
|
||||||
fg.col({}, {
|
fg.col({}, {
|
||||||
fg.label(c, "Store", "h1", 40), fg.space(0, 4),
|
fg.label(c, "Store", "h1", 40), fg.space(0, 4),
|
||||||
fg.text_para(c, "Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Equip attachments in Equipment.",
|
fg.text_para(c, "Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Upgrade them in Equipment.",
|
||||||
{ font = "sans", px = 15, color = "muted" }, { max_width = math.min(inner, S.text_max) }),
|
{ font = "sans", px = 15, color = "muted" }, { max_width = math.min(inner, S.text_max) }),
|
||||||
fg.space(0, 20),
|
fg.space(0, 20),
|
||||||
}),
|
}),
|
||||||
@@ -231,12 +210,6 @@ function M.build(self, root, px, py, w, h, H)
|
|||||||
tool_card(self, H, st, cd, t)
|
tool_card(self, H, st, cd, t)
|
||||||
end, { key = function(t) return "store:tool:" .. tostring(t.storeItemId) end, sig = shared })
|
end, { key = function(t) return "store:tool:" .. tostring(t.storeItemId) end, sig = shared })
|
||||||
end,
|
end,
|
||||||
attachments and cap("Attachments", 8) or nil,
|
|
||||||
attachments and function(cy)
|
|
||||||
return card.grid(self, c, x, cy, inner, sto.attachments, function(cd, a)
|
|
||||||
gear_card(self, H, st, cd, a)
|
|
||||||
end, { key = function(a) return "store:gear:" .. tostring(a.name) end, sig = shared })
|
|
||||||
end or nil,
|
|
||||||
})
|
})
|
||||||
area:finish(y + py)
|
area:finish(y + py)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
- characters the fonts lack: non-ASCII characters in Lua string literals and in a
|
- characters the fonts lack: non-ASCII characters in Lua string literals and in a
|
||||||
sample of server texts (GET /api/state of a fixture player, every section) must be in
|
sample of server texts (GET /api/state of a fixture player, every section) must be in
|
||||||
the fonts that draw them (text fonts; the symbols ui.rich sends to the sym font)
|
the fonts that draw them (text fonts; the symbols ui.rich sends to the sym font)
|
||||||
- the moon generator (scripts/moon-check.mjs: the mmterrain extension's C, built for the
|
|
||||||
host, and terrain.lua's settings side, against the server's terrain.js)
|
|
||||||
- engine warnings in a scenario run's log: font batches / characters / text entries
|
- engine warnings in a scenario run's log: font batches / characters / text entries
|
||||||
over their limits, out of nodes or animations, stencil overflow, invalid markup,
|
over their limits, out of nodes or animations, stencil overflow, invalid markup,
|
||||||
script errors
|
script errors
|
||||||
@@ -231,15 +229,6 @@ def check_scenario(api):
|
|||||||
check_log(os.path.join(ROOT, "dist/drive/check/game.log"))
|
check_log(os.path.join(ROOT, "dist/drive/check/game.log"))
|
||||||
|
|
||||||
|
|
||||||
def check_moon():
|
|
||||||
r = subprocess.run(["node", os.path.join(HERE, "moon-check.mjs")], cwd=ROOT, stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT, text=True)
|
|
||||||
if r.returncode != 0:
|
|
||||||
fail("moon-check.mjs failed:\n" + "\n".join(l for l in r.stdout.splitlines() if not l.startswith("ok "))[-2000:])
|
|
||||||
else:
|
|
||||||
print("moon: " + (r.stdout.strip().splitlines() or [""])[-1])
|
|
||||||
|
|
||||||
|
|
||||||
def check_flex():
|
def check_flex():
|
||||||
r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE,
|
r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT, text=True)
|
stderr=subprocess.STDOUT, text=True)
|
||||||
@@ -257,7 +246,6 @@ def main():
|
|||||||
check_lua()
|
check_lua()
|
||||||
check_flags()
|
check_flags()
|
||||||
check_flex()
|
check_flex()
|
||||||
check_moon()
|
|
||||||
check_fonts(api)
|
check_fonts(api)
|
||||||
if "--static" not in sys.argv:
|
if "--static" not in sys.argv:
|
||||||
check_scenario(api)
|
check_scenario(api)
|
||||||
|
|||||||
@@ -63,11 +63,14 @@ def read(name, default=None):
|
|||||||
|
|
||||||
|
|
||||||
def alive(pid):
|
def alive(pid):
|
||||||
|
# Our game or Xvfb still: the pid file outlives a game that exits on its own, and its
|
||||||
|
# number can be reused by another process (stop would kill that one's group).
|
||||||
try:
|
try:
|
||||||
os.kill(int(pid), 0)
|
with open("/proc/%d/cmdline" % int(pid), "rb") as f:
|
||||||
return True
|
cmd = f.read()
|
||||||
except (OSError, ValueError, TypeError):
|
except (OSError, ValueError, TypeError):
|
||||||
return False
|
return False
|
||||||
|
return any(n in cmd for n in (b"dmengine", b"MoonMiners", b"Xvfb"))
|
||||||
|
|
||||||
|
|
||||||
def snapshot():
|
def snapshot():
|
||||||
|
|||||||
+15
-10
@@ -11,18 +11,19 @@ Usage:
|
|||||||
scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
|
scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
|
||||||
with run(game, args).
|
with run(game, args).
|
||||||
Options:
|
Options:
|
||||||
--api URL server (default http://localhost:3000; MM_API env)
|
--api URL server (or MM_API; required, no default: a test server, never :3000)
|
||||||
--size WxH window and display size (default 1280x800)
|
--size WxH window and display size (default 1280x800)
|
||||||
--out DIR screenshots and game.log (default dist/drive/<scenario>)
|
--out DIR screenshots and game.log (default dist/drive/<scenario>)
|
||||||
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default
|
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default
|
||||||
--keep don't stop the game at the end (prints its pid and port)
|
--keep don't stop the game, or its Xvfb, at the end (prints their pids, the
|
||||||
|
port and the display)
|
||||||
--attach PORT don't start a game: drive the one whose engine service is on PORT
|
--attach PORT don't start a game: drive the one whose engine service is on PORT
|
||||||
(`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
|
(`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
|
||||||
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
|
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
|
||||||
--log FILE the attached game's log (default dist/dev/game.log)
|
--log FILE the attached game's log (default dist/dev/game.log)
|
||||||
--config K=V extra engine config (mm.player=..., repeatable)
|
--config K=V extra engine config (mm.player=..., repeatable)
|
||||||
--fixture make a test player first (scripts/fixture.py against --api, which must
|
--fixture make a test player first (scripts/fixture.py against --api) and play as
|
||||||
then be given, or MM_API) and play as it; the scenario gets its ids as game.fixture
|
it; the scenario gets its ids as game.fixture
|
||||||
--fixture-file F use the ids saved in F (a fixture.py output) instead
|
--fixture-file F use the ids saved in F (a fixture.py output) instead
|
||||||
|
|
||||||
The game it starts saves its files (player ids, settings) under <out>/data (or
|
The game it starts saves its files (player ids, settings) under <out>/data (or
|
||||||
@@ -381,7 +382,7 @@ def main():
|
|||||||
ap = argparse.ArgumentParser(add_help=True)
|
ap = argparse.ArgumentParser(add_help=True)
|
||||||
ap.add_argument("scenario")
|
ap.add_argument("scenario")
|
||||||
ap.add_argument("rest", nargs="*")
|
ap.add_argument("rest", nargs="*")
|
||||||
ap.add_argument("--api", default=os.environ.get("MM_API", "http://localhost:3000"))
|
ap.add_argument("--api", default=os.environ.get("MM_API"))
|
||||||
ap.add_argument("--size", default="1280x800")
|
ap.add_argument("--size", default="1280x800")
|
||||||
ap.add_argument("--out")
|
ap.add_argument("--out")
|
||||||
ap.add_argument("--bundle", action="store_true")
|
ap.add_argument("--bundle", action="store_true")
|
||||||
@@ -392,6 +393,9 @@ def main():
|
|||||||
ap.add_argument("--fixture", action="store_true")
|
ap.add_argument("--fixture", action="store_true")
|
||||||
ap.add_argument("--fixture-file")
|
ap.add_argument("--fixture-file")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
if not args.api:
|
||||||
|
# (No default: the game makes a player and plays on whatever server this is.)
|
||||||
|
sys.exit("drive.py: pass --api or set MM_API to a test server (no default)")
|
||||||
|
|
||||||
if args.attach:
|
if args.attach:
|
||||||
disp = os.path.join(ROOT, "dist/dev/display")
|
disp = os.path.join(ROOT, "dist/dev/display")
|
||||||
@@ -413,8 +417,12 @@ def main():
|
|||||||
try:
|
try:
|
||||||
code = subprocess.call([sys.executable] + sys.argv, env=env)
|
code = subprocess.call([sys.executable] + sys.argv, env=env)
|
||||||
finally:
|
finally:
|
||||||
xvfb.terminate()
|
if args.keep:
|
||||||
xvfb.wait()
|
# The kept game draws on this display: leave it running too.
|
||||||
|
print("Xvfb pid %d, display :%s" % (xvfb.pid, num))
|
||||||
|
else:
|
||||||
|
xvfb.terminate()
|
||||||
|
xvfb.wait()
|
||||||
sys.exit(code)
|
sys.exit(code)
|
||||||
|
|
||||||
path = find_scenario(args.scenario)
|
path = find_scenario(args.scenario)
|
||||||
@@ -428,9 +436,6 @@ def main():
|
|||||||
if not args.attach:
|
if not args.attach:
|
||||||
args.config.append("mm.player=" + fixture["player"])
|
args.config.append("mm.player=" + fixture["player"])
|
||||||
elif args.fixture:
|
elif args.fixture:
|
||||||
if "--api" not in sys.argv and not os.environ.get("MM_API"):
|
|
||||||
sys.exit("drive.py: --fixture creates a player on the server: pass --api or set MM_API "
|
|
||||||
"to a test server (no default)")
|
|
||||||
fixture = json.loads(subprocess.check_output([sys.executable, os.path.join(HERE, "fixture.py"), args.api]))
|
fixture = json.loads(subprocess.check_output([sys.executable, os.path.join(HERE, "fixture.py"), args.api]))
|
||||||
args.config.append("mm.player=" + fixture["player"])
|
args.config.append("mm.player=" + fixture["player"])
|
||||||
print("fixture player", fixture["player"])
|
print("fixture player", fixture["player"])
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Usage: scripts/fixture.py <api base> [--plain]
|
|||||||
Prints one JSON line: {"player", "moon", "well", "wells", "tool", "drill_mining", ...}
|
Prints one JSON line: {"player", "moon", "well", "wells", "tool", "drill_mining", ...}
|
||||||
that scenarios (scripts/drive.py) use for views. The player: every section unlocked,
|
that scenarios (scripts/drive.py) use for views. The player: every section unlocked,
|
||||||
credits, resources and refined items granted, two moons, several tools (some drilling,
|
credits, resources and refined items granted, two moons, several tools (some drilling,
|
||||||
one ready, one idle with an attachment), a refinery batch running, an advisor hired.
|
one ready, one idle with upgraded tracks), a refinery batch running, an advisor hired.
|
||||||
--plain: just a new player (nothing granted), for the first-moon views.
|
--plain: just a new player (nothing granted), for the first-moon views.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
@@ -66,8 +66,8 @@ admin("credits", amount=500000, op="grant")
|
|||||||
for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance",
|
for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance",
|
||||||
"Dusk Alloy", "Cryo Cell", "Plasma Weave"]:
|
"Dusk Alloy", "Cryo Cell", "Plasma Weave"]:
|
||||||
admin("resources", resource=r, amount=400)
|
admin("resources", resource=r, amount=400)
|
||||||
for item, tier in [(1, 0), (1, 0), (2, 1), (3, 0), (5, 2)]:
|
for item, yl, cl in [(1, 0, 0), (1, 0, 0), (2, 0, 1), (3, 0, 0), (5, 2, 1)]:
|
||||||
admin("tool", item=item, tier=tier)
|
admin("tool", item=item, **{"yield": yl, "cycle": cl})
|
||||||
|
|
||||||
act("buy-moon")
|
act("buy-moon")
|
||||||
d = act("buy-moon")
|
d = act("buy-moon")
|
||||||
@@ -89,11 +89,11 @@ act("stake", tool=long_tool, target=wells[0]["id"])
|
|||||||
act("stake", tool=by_item[2][0]["id"], target=wells[1 % len(wells)]["id"])
|
act("stake", tool=by_item[2][0]["id"], target=wells[1 % len(wells)]["id"])
|
||||||
act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"])
|
act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"])
|
||||||
|
|
||||||
act("buy-attachment", item="Turbo Core")
|
# The idle PD-1 a level up on each track (its yield track twice).
|
||||||
act("buy-attachment", item="Diamond Bit")
|
|
||||||
act("buy-attachment", item="Diamond Bit")
|
|
||||||
idle = by_item[1][1]["id"]
|
idle = by_item[1][1]["id"]
|
||||||
act("equip", item="Turbo Core", to=idle)
|
act("upgrade", tool=idle, track="yield")
|
||||||
|
act("upgrade", tool=idle, track="yield")
|
||||||
|
act("upgrade", tool=idle, track="cycle")
|
||||||
|
|
||||||
act("buy-slot")
|
act("buy-slot")
|
||||||
act("refine", recipe="Dusk Alloy", qty=5)
|
act("refine", recipe="Dusk Alloy", qty=5)
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
// Checks the moon generator against the server's terrain.js: the native one
|
|
||||||
// (mmterrain/src/terrain.c, built here for the host and loaded into luajit through the
|
|
||||||
// FFI) gives the same mesh within float32 rounding, and the Lua settings side
|
|
||||||
// (modules/moon/terrain.lua: random draws, traits, resolve, the strip) matches too.
|
|
||||||
// Every seed runs with the default settings and a few others (each technique, many
|
|
||||||
// craters, no ridges or patches). Needs node, luajit and a C compiler (cc).
|
|
||||||
//
|
|
||||||
// node scripts/moon-check.mjs [resolution] [seeds...]
|
|
||||||
//
|
|
||||||
// terrain.js is read from $MM_SERVER (default ../server).
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
||||||
const server = process.env.MM_SERVER || path.join(root, "..", "server");
|
|
||||||
const T = await import(pathToFileURL(path.join(server, "web/static/js/terrain.js")));
|
|
||||||
const [res = "32", ...rest] = process.argv.slice(2);
|
|
||||||
// The fixed seeds, plus moon-id-like ones (deterministic).
|
|
||||||
const ids = [];
|
|
||||||
{
|
|
||||||
let h = 0x9e3779b9;
|
|
||||||
const next = () => ((h = (Math.imul(h ^ (h >>> 15), 0x2c1b3c6d) + 0x6d2b79f5) >>> 0), h.toString(16).padStart(8, "0"));
|
|
||||||
for (let i = 0; i < 24; i++) ids.push(`${next()}-${next().slice(0, 4)}-4${next().slice(0, 3)}-a${next().slice(0, 3)}-${next()}${next().slice(0, 4)}`);
|
|
||||||
}
|
|
||||||
const seeds = rest.length ? rest : ["1042", "1187", "1301", "1555", "29dea5d5-709d-4c55-a269-cd90e4379fe9", ...ids];
|
|
||||||
const fixed = (t) => ({ technique: t, variation: { technique: false } });
|
|
||||||
const SETTINGS = [
|
|
||||||
{}, fixed("fbm"), fixed("ridged"), fixed("warped"), fixed("billow"), fixed("craterField"),
|
|
||||||
{ craters: { count: 1500, smoothness: 0, maxRadius: 0.4 } }, { ridge: { strength: 0 }, shading: { patchAmount: 0 } },
|
|
||||||
];
|
|
||||||
// Each case: a seed and a settings index (the default settings for every seed, the
|
|
||||||
// others over a rotating subset).
|
|
||||||
const cases = [];
|
|
||||||
seeds.forEach((seed, i) => {
|
|
||||||
cases.push([seed, 0]);
|
|
||||||
for (let s = 1; s < SETTINGS.length; s++) if ((i + s) % 4 === 0 || rest.length) cases.push([seed, s]);
|
|
||||||
});
|
|
||||||
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mmterrain-"));
|
|
||||||
const lib = path.join(dir, "libmmterrain.so");
|
|
||||||
execFileSync("cc", ["-O2", "-std=c99", "-shared", "-fPIC", "-o", lib, path.join(root, "mmterrain/src/terrain.c"), "-lm"]);
|
|
||||||
|
|
||||||
const luaValue = (v) => (typeof v === "object" ? "{" + Object.entries(v).map(([k, x]) => `${k}=${luaValue(x)}`).join(",") + "}" : JSON.stringify(v));
|
|
||||||
const lua = `
|
|
||||||
package.path = ${JSON.stringify(root + "/?.lua;")} .. package.path
|
|
||||||
local T = require("modules.moon.terrain")
|
|
||||||
local SETTINGS = { ${SETTINGS.map(luaValue).join(", ")} }
|
|
||||||
local ffi = require("ffi")
|
|
||||||
ffi.cdef[[
|
|
||||||
typedef struct mt_params mt_params;
|
|
||||||
typedef struct mt_gen mt_gen;
|
|
||||||
int mt_params_from_array(const double* a, int n, mt_params* p);
|
|
||||||
mt_gen* mt_new(const mt_params* p);
|
|
||||||
void mt_free(mt_gen* g);
|
|
||||||
int mt_step(mt_gen* g, double budget);
|
|
||||||
int mt_count(const mt_gen* g);
|
|
||||||
int mt_n(const mt_gen* g);
|
|
||||||
const double* mt_positions(const mt_gen* g);
|
|
||||||
const double* mt_normals(const mt_gen* g);
|
|
||||||
const double* mt_colors(const mt_gen* g);
|
|
||||||
int mt_strip_len(int n);
|
|
||||||
void mt_strip(int n, int* out);
|
|
||||||
void mt_fill(const mt_gen* g, float* pos, float* nor, float* col, int stride, int from, int to);
|
|
||||||
]]
|
|
||||||
local N = ffi.load(${JSON.stringify(lib)})
|
|
||||||
local function num(x) return string.format("%.17g", x) end
|
|
||||||
local function carr(p, n) local o = {} for k = 0, n - 1 do o[k + 1] = num(p[k]) end return "[" .. table.concat(o, ",") .. "]" end
|
|
||||||
local out = {}
|
|
||||||
for i = 2, #arg, 2 do -- arg[1]: resolution, then seed, settings index pairs
|
|
||||||
local seed, si = arg[i], tonumber(arg[i + 1])
|
|
||||||
local s = T.merge_settings(SETTINGS[si + 1])
|
|
||||||
local r = T.rng(T.hash_string(seed))
|
|
||||||
local draws = {} for k = 1, 16 do draws[k] = num(r()) end
|
|
||||||
local tr = T.traits(s, seed)
|
|
||||||
local p = T.resolve(s, seed)
|
|
||||||
p.resolution = tonumber(arg[1])
|
|
||||||
do
|
|
||||||
local a = T.native_params(p)
|
|
||||||
local buf = ffi.new("double[?]", #a, a)
|
|
||||||
local params = ffi.new("char[1024]") -- (larger than mt_params)
|
|
||||||
assert(N.mt_params_from_array(buf, #a, ffi.cast("mt_params*", params)) == 1)
|
|
||||||
local g = N.mt_new(ffi.cast("mt_params*", params))
|
|
||||||
local steps = 0
|
|
||||||
repeat steps = steps + 1 until N.mt_step(g, 0.0005) == 1
|
|
||||||
local c = N.mt_count(g) * 3
|
|
||||||
-- The strip: the native order equals the Lua's (terrain.strip), and its fill writes
|
|
||||||
-- those vertices.
|
|
||||||
local strip = T.strip(N.mt_n(g))
|
|
||||||
local len = N.mt_strip_len(N.mt_n(g))
|
|
||||||
local sidx = ffi.new("int[?]", len)
|
|
||||||
N.mt_strip(N.mt_n(g), sidx)
|
|
||||||
local same = len == #strip
|
|
||||||
for k = 1, #strip do if not same then break end same = sidx[k - 1] == strip[k] end
|
|
||||||
local fill = ffi.new("float[?]", len * 9)
|
|
||||||
local half = math.floor(len / 2) -- (in two ranges, like the web's chunks)
|
|
||||||
N.mt_fill(g, fill, fill + 3, fill + 6, 9, 0, half)
|
|
||||||
N.mt_fill(g, fill, fill + 3, fill + 6, 9, half, len)
|
|
||||||
local P = N.mt_positions(g)
|
|
||||||
local fdiff = 0
|
|
||||||
for k = 1, #strip do
|
|
||||||
local v = strip[k]
|
|
||||||
for d = 0, 2 do fdiff = math.max(fdiff, math.abs(fill[(k - 1) * 9 + d] - P[v * 3 + d])) end
|
|
||||||
end
|
|
||||||
out[#out + 1] = string.format('{"draws":[%s],"traits":[%q,%q,%s,%s,%s,%s],"positions":%s,"normals":%s,"colors":%s,"strip":%s,"fill":%s,"steps":%d}',
|
|
||||||
table.concat(draws, ","), tr.technique, tr.mid, num(tr.shapeStrength), num(tr.ridgeStrength), num(tr.craterCount),
|
|
||||||
num(tr.maxRadius), carr(N.mt_positions(g), c), carr(N.mt_normals(g), c), carr(N.mt_colors(g), c),
|
|
||||||
same and "true" or "false", num(fdiff), steps)
|
|
||||||
N.mt_free(g)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
print("[" .. table.concat(out, ",") .. "]")
|
|
||||||
`;
|
|
||||||
const got = JSON.parse(execFileSync("luajit", ["-", res, ...cases.flat().map(String)], { input: lua, maxBuffer: 1 << 30 }).toString());
|
|
||||||
|
|
||||||
const maxDiff = (a, b) => (a.length !== b.length ? Infinity : a.reduce((d, v, k) => Math.max(d, Math.abs(v - b[k])), 0));
|
|
||||||
const close = (m, g) => {
|
|
||||||
const d = { positions: maxDiff(m.positions, g.positions), normals: maxDiff(m.normals, g.normals), colors: maxDiff(m.colors, g.colors) };
|
|
||||||
return [d.positions < 1e-5 && d.normals < 1e-4 && d.colors < 1e-4, d];
|
|
||||||
};
|
|
||||||
const fmt = (d) => `positions ${d.positions.toExponential(1)}, normals ${d.normals.toExponential(1)}, colors ${d.colors.toExponential(1)}`;
|
|
||||||
let failed = false;
|
|
||||||
cases.forEach(([seed, si], i) => {
|
|
||||||
const s = T.mergeSettings(SETTINGS[si]);
|
|
||||||
const r = T.rng(T.hashString(seed));
|
|
||||||
const draws = Array.from({ length: 16 }, () => r());
|
|
||||||
const t = T.moonTraits(s, seed);
|
|
||||||
const traits = [t.technique, t.mid, t.shapeStrength, t.ridgeStrength, t.craterCount, t.maxRadius];
|
|
||||||
const p = T.resolveMoon(s, seed);
|
|
||||||
p.resolution = Number(res);
|
|
||||||
const m = T.generateMoon(p);
|
|
||||||
const exact = JSON.stringify(draws) === JSON.stringify(got[i].draws) && JSON.stringify(traits) === JSON.stringify(got[i].traits);
|
|
||||||
const nat = got[i];
|
|
||||||
const [natOk, d] = close(m, nat);
|
|
||||||
const ok = exact && natOk && nat.strip && nat.fill < 1e-5;
|
|
||||||
const line = `draws and traits ${exact ? "identical" : "DIFFER"} | native: ${fmt(d)}; strip ${nat.strip ? "same" : "DIFFERS"}, fill ${nat.fill.toExponential(1)}, ${nat.steps} steps`;
|
|
||||||
failed ||= !ok;
|
|
||||||
console.log(`${ok ? "ok " : "FAIL"} ${seed} [settings ${si}] ${t.technique}: ${line}`);
|
|
||||||
});
|
|
||||||
console.log(`${cases.length} cases at resolution ${res}: ${failed ? "FAILED" : "all ok"}`);
|
|
||||||
process.exit(failed ? 1 : 0);
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Card sections (needs --fixture): a click on a tool card opens its drill panel, a click
|
"""Card sections (needs --fixture): a click on a tool row (Equipment) opens its drill panel, a click
|
||||||
on an advisor card shows it in the aside; hover lights a card's edge; seat tiles and the
|
on an advisor card shows it in the aside; hover lights a card's edge; seat tiles and the
|
||||||
close are focusable. Shots of each."""
|
close are focusable. Shots of each."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""The drill panel during onboarding (needs --fixture-file of a plain player:
|
"""The drill panel during onboarding (needs --fixture-file of a plain player:
|
||||||
scripts/fixture.py <api> --plain, and an MM_DEV=1 server): once the first deploy reveals
|
scripts/fixture.py <api> --plain, and an MM_DEV=1 server): once the first deploy reveals
|
||||||
Equipment, a tool card opens its panel (stats, tiers) without the Store's parts (upgrade,
|
Equipment, a tool row opens its panel (stats, the tracks' ladders) without the Store's
|
||||||
slots, spares, Sell drill); after the first claim and sale reveal the Store, the same panel
|
parts (the upgrade buttons, Sell drill); after the first claim and sale reveal the Store,
|
||||||
has them. Shots "before" and "after", each linted."""
|
the same panel has them. Shots "before" and "after", each linted."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -41,10 +41,10 @@ def run(game, args):
|
|||||||
game.shot("before")
|
game.shot("before")
|
||||||
game.lint("before")
|
game.lint("before")
|
||||||
texts = labels(game)
|
texts = labels(game)
|
||||||
game.expect("tiers" in texts, "tier ladder shown")
|
game.expect("yield per cycle" in texts and "level" in texts, "track ladders shown")
|
||||||
for t in ("slots", "spare attachments", "sell drill"):
|
game.expect("sell drill" not in texts, "sell drill hidden before the Store")
|
||||||
game.expect(t not in texts, "%s hidden before the Store" % t)
|
ups = [e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?")]
|
||||||
game.expect(not any(t.startswith("upgrade to") for t in texts), "no upgrade before the Store")
|
game.expect(not ups, "no upgrade before the Store: %s" % ups)
|
||||||
|
|
||||||
# First claim (Market), first sale (Store).
|
# First claim (Market), first sale (Store).
|
||||||
admin(game, "finish-stakes")
|
admin(game, "finish-stakes")
|
||||||
@@ -60,5 +60,8 @@ def run(game, args):
|
|||||||
game.shot("after")
|
game.shot("after")
|
||||||
game.lint("after")
|
game.lint("after")
|
||||||
texts = labels(game)
|
texts = labels(game)
|
||||||
for t in ("tiers", "slots", "spare attachments", "sell drill"):
|
for t in ("yield per cycle", "sell drill"):
|
||||||
game.expect(t in texts, "%s shown after the Store" % t)
|
game.expect(t in texts, "%s shown after the Store" % t)
|
||||||
|
ups = {e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?")}
|
||||||
|
for kind in ("yield", "cycle"):
|
||||||
|
game.expect(any(("track=" + kind) in i for i in ups), "%s upgrade after the Store: %s" % (kind, ups))
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""The Equipment table (needs --fixture): a row per tool, copies too (the fixture's two
|
||||||
|
PD-1s), one mining, one ready; the idle PD-1's cycle track taken to its top (Max in place
|
||||||
|
of its button) and the credits cut to a few hundred (some upgrades short). Shots of the
|
||||||
|
table, a row's drill panel, a row's Sell asking in place (Cancel on Sell's spot), and two
|
||||||
|
clicks on an affordable upgrade button (click_check: it stays put). Each linted. Args: an
|
||||||
|
optional name prefix."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from lists import admin, call # noqa: E402
|
||||||
|
from sellask import ask, covers, rect_of # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def run(game, args):
|
||||||
|
fx = game.fixture
|
||||||
|
pre = (args[0] + "-") if args else ""
|
||||||
|
idle = fx["tool_idle"]
|
||||||
|
game.idle()
|
||||||
|
for _ in range(12):
|
||||||
|
r = call(game, "POST", "/api/act/upgrade", {"tool": idle, "track": "cycle"})
|
||||||
|
if not r.get("ok"):
|
||||||
|
break
|
||||||
|
st = call(game, "GET", "/api/state?s=equipment")["state"]
|
||||||
|
tr = [t for t in st["equipment"]["tools"] if t["id"] == idle][0]["tracks"][1]
|
||||||
|
game.expect(tr["next"] is None, "cycle track at its top: %s/%s" % (tr["level"], tr["max"]))
|
||||||
|
# One mining for sure (the fixture's stakes can find their well empty): an idle tool
|
||||||
|
# other than the PD-1s on the second moon.
|
||||||
|
ms = call(game, "GET", "/api/state?s=moons&moon=" + fx["moon2"])["state"]
|
||||||
|
wells = [w for w in ms["moon"]["wells"] if w["supply"] > 0]
|
||||||
|
spare = [t for t in st["equipment"]["tools"] if not t["stake"] and t["storeItemId"] != 1]
|
||||||
|
if wells and spare:
|
||||||
|
r = call(game, "POST", "/api/act/stake", {"tool": spare[0]["id"], "target": wells[0]["id"]})
|
||||||
|
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
|
||||||
|
admin(game, "credits", amount=max(1, st["credits"] - 260), op="remove")
|
||||||
|
game.navigate("{section='equipment'}")
|
||||||
|
game.idle()
|
||||||
|
game.sleep(1)
|
||||||
|
game.shot(pre + "eqrows-table", settle=1.0)
|
||||||
|
game.lint(pre + "eqrows-table")
|
||||||
|
|
||||||
|
# A row opens its drill panel.
|
||||||
|
game.scroll_to("drill:" + idle)
|
||||||
|
game.idle()
|
||||||
|
game.click("drill:" + idle)
|
||||||
|
game.wait_for("view.drill == '%s' and not g.busy" % idle)
|
||||||
|
game.idle()
|
||||||
|
game.shot(pre + "eqrows-panel", settle=1.0)
|
||||||
|
game.lint(pre + "eqrows-panel")
|
||||||
|
|
||||||
|
# A row's Sell asks in place.
|
||||||
|
sell = "confirm:sell-tool:" + idle
|
||||||
|
game.scroll_to(sell)
|
||||||
|
game.idle()
|
||||||
|
before = rect_of(game, sell)
|
||||||
|
ask(game, sell)
|
||||||
|
game.expect(not game.eval("g.dialog:is_open()"), "no dialog opens")
|
||||||
|
game.expect(covers(rect_of(game, sell + ":cancel"), before), "Cancel takes Sell's spot")
|
||||||
|
game.shot(pre + "eqrows-asking", settle=0.3)
|
||||||
|
game.lint(pre + "eqrows-asking")
|
||||||
|
game.key("key_esc")
|
||||||
|
game.wait_for("g.ask == nil", 5)
|
||||||
|
game.idle()
|
||||||
|
|
||||||
|
# Two clicks on an affordable upgrade keep hitting the same row's button.
|
||||||
|
ups = [e for e in game.elements() if e["id"].startswith("act:upgrade?") and e.get("enabled", True)]
|
||||||
|
game.expect(ups, "an affordable upgrade")
|
||||||
|
if ups:
|
||||||
|
id_ = ups[0]["id"]
|
||||||
|
for n in range(2):
|
||||||
|
if game.eval("el(%r)" % id_):
|
||||||
|
game.click_check(id_, name=pre + "eqrows-click-%d" % n)
|
||||||
|
game.shot(pre + "eqrows-after", settle=1.0)
|
||||||
@@ -34,7 +34,7 @@ def run(game, args):
|
|||||||
clicks = [
|
clicks = [
|
||||||
("moons", "act:claim?claim-well"),
|
("moons", "act:claim?claim-well"),
|
||||||
("refinery", "act:collect?job"),
|
("refinery", "act:collect?job"),
|
||||||
("market", "act:sell"),
|
("market", "act:sell?pick"),
|
||||||
("store", "act:buy-tool?id"),
|
("store", "act:buy-tool?id"),
|
||||||
("equipment", "act:upgrade?tool"),
|
("equipment", "act:upgrade?tool"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
scenario may change: MM_DB, default mm_c_lists, in the podman container MM_DB_CONTAINER,
|
scenario may change: MM_DB, default mm_c_lists, in the podman container MM_DB_CONTAINER,
|
||||||
default moonminers_db_1). Shots: a depleted moon row (and hovered), the mined-out
|
default moonminers_db_1). Shots: a depleted moon row (and hovered), the mined-out
|
||||||
collapse and the Past tab flash, the greyed Past list, a new moon row entering, a new
|
collapse and the Past tab flash, the greyed Past list, a new moon row entering, a new
|
||||||
drill entering (wells table and well panel), deploy cards short and spoken for."""
|
drill entering (well card and well panel), the deploy slot short and with no room."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -98,9 +98,9 @@ def run(game, args):
|
|||||||
burst(game, "newmoon", [0.05, 0.3, 0.6, 1.2])
|
burst(game, "newmoon", [0.05, 0.3, 0.6, 1.2])
|
||||||
game.idle()
|
game.idle()
|
||||||
|
|
||||||
# Deploy cards: tools idle after claiming moon 1; a well with little left: a stake
|
# Deploying: tools idle after claiming moon 1; a well with little left: a deploy from
|
||||||
# from the well panel (the new drill enters in the table and the panel), then the
|
# its card's slot (the new drill enters in the card and the well panel), then the slot
|
||||||
# other cards come up short or spoken for.
|
# comes up short ("Only +N left here"), then "No room here" once nothing is left.
|
||||||
game.navigate("{section='moons', moon='%s'}" % fx["moon"])
|
game.navigate("{section='moons', moon='%s'}" % fx["moon"])
|
||||||
game.idle()
|
game.idle()
|
||||||
if game.eval("st.ready") > 0:
|
if game.eval("st.ready") > 0:
|
||||||
@@ -112,24 +112,17 @@ def run(game, args):
|
|||||||
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
|
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
|
||||||
game.idle()
|
game.idle()
|
||||||
game.shot("deploy")
|
game.shot("deploy")
|
||||||
# (ids from the state: the elements reply can be long)
|
slot = "act:stake?target=%s" % well
|
||||||
def card(i):
|
first = next((e["id"] for e in game.elements() if e["id"].startswith(slot)), None)
|
||||||
tool = game.eval("st.well.idle[%d] and st.well.idle[%d].toolId" % (i, i))
|
game.expect(first, "the well's deploy slot is there")
|
||||||
return tool and "act:stake?target=%s&tool=%s" % (well, tool)
|
|
||||||
first = card(1)
|
|
||||||
game.expect(first, "deploy cards are there")
|
|
||||||
game.scroll_to(first) # (the cards and the drills list under them in view)
|
|
||||||
if first:
|
if first:
|
||||||
|
game.scroll_to(first)
|
||||||
game.click(first)
|
game.click(first)
|
||||||
burst(game, "newdrill", [0.05, 0.3, 0.6, 1.2])
|
burst(game, "newdrill", [0.05, 0.3, 0.6, 1.2])
|
||||||
game.idle()
|
game.idle()
|
||||||
game.shot("deploy-short", settle=0.5)
|
game.shot("deploy-short", settle=0.5)
|
||||||
# Nothing left after the drills: spoken for (dimmed; less so under the mouse).
|
# Nothing left after the drills: no room.
|
||||||
psql("update resource_well set supply = 2 where id = '%s'" % well)
|
psql("update resource_well set supply = 2 where id = '%s'" % well)
|
||||||
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
|
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
|
||||||
game.idle()
|
game.idle()
|
||||||
game.shot("deploy-spoken", settle=0.5)
|
game.shot("deploy-spoken", settle=0.5)
|
||||||
n = game.eval("#st.well.idle")
|
|
||||||
if n:
|
|
||||||
game.hover(card(n))
|
|
||||||
game.shot("deploy-hover", settle=0.4)
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Moon lab (admin area, screens/game/moonlab.lua): the gear menu's Moon lab opens it (needs
|
||||||
|
an MM_DEV=1 server, where everyone is an admin, or a debug build), the saved settings load,
|
||||||
|
the layout lints, a real mouse drag on a slider follows the pointer and rebuilds the moon
|
||||||
|
(the build times are printed: "moon: ... built, N vertices, M ms"), Save posts the
|
||||||
|
settings (GET answers them back), and Back to the game leaves the lab with the game's
|
||||||
|
moon on the saved settings."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
M = "require('modules.moon.moon')"
|
||||||
|
|
||||||
|
|
||||||
|
def built(game):
|
||||||
|
game.wait_for("not %s.building and not g.lab.pending" % M)
|
||||||
|
return game.eval("%s.last_build and %s.last_build.ms" % (M, M))
|
||||||
|
|
||||||
|
|
||||||
|
def run(game, args):
|
||||||
|
game.idle()
|
||||||
|
game.click("settings")
|
||||||
|
game.wait_for("g.dialog:is_open()")
|
||||||
|
game.sleep(0.3)
|
||||||
|
game.click("menu:moon-lab")
|
||||||
|
game.wait_for("g.admin == 'moonlab' and g.lab and g.lab.loaded")
|
||||||
|
game.idle()
|
||||||
|
ms0 = built(game)
|
||||||
|
game.shot("moonlab", settle=0.5)
|
||||||
|
game.lint("moonlab")
|
||||||
|
|
||||||
|
# A click on a slider's track: the value under the pointer (the middle).
|
||||||
|
game.scroll_to("slider:craters.count")
|
||||||
|
game.click("slider:craters.count")
|
||||||
|
game.expect(game.eval("g.lab.draft.craters.count") == 1000, "a click in the middle sets the middle")
|
||||||
|
ms1 = built(game)
|
||||||
|
|
||||||
|
# A real drag across a slider: the knob follows, the moon rebuilds as it goes.
|
||||||
|
game.scroll_to("slider:shape.strength")
|
||||||
|
x, y = game.os_point("slider:shape.strength")
|
||||||
|
game.os_move(x - 40, y)
|
||||||
|
game.xdo("mousedown", "1")
|
||||||
|
t0 = time.time()
|
||||||
|
for i in range(20):
|
||||||
|
game.os_move(x - 40 + i * 8, y)
|
||||||
|
time.sleep(0.03)
|
||||||
|
game.xdo("mouseup", "1")
|
||||||
|
game.idle()
|
||||||
|
v = game.eval("g.lab.draft.shape.strength")
|
||||||
|
ms2 = built(game)
|
||||||
|
print("moonlab: build ms first %s, click %s, after drag %s (drag ended at strength %s, %.1f s)" % (ms0, ms1, ms2, v, time.time() - t0))
|
||||||
|
game.expect(v and v > 0.1, "the drag moved the slider right of the middle: %r" % v)
|
||||||
|
game.shot("moonlab-dragged", settle=0.3)
|
||||||
|
|
||||||
|
game.click_check("lab:save")
|
||||||
|
game.wait_for("not g.lab.saving")
|
||||||
|
game.expect(game.eval("g.lab.error") is None, "saved")
|
||||||
|
with urllib.request.urlopen(game.api + "/api/moon-settings") as r:
|
||||||
|
saved = json.load(r)
|
||||||
|
game.expect(saved["craters"]["count"] == 1000, "the server has the new settings")
|
||||||
|
|
||||||
|
game.click("admin:back")
|
||||||
|
game.wait_for("g.admin == nil")
|
||||||
|
game.idle()
|
||||||
|
game.expect(game.eval("%s.settings.craters.count" % M) == 1000, "the game's moon uses the saved settings")
|
||||||
|
game.shot("moonlab-back", settle=0.5)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""The moon view and well panel in the web's look (needs --fixture and a local dev server
|
"""The moon view and well panel (needs --fixture and a local dev server whose database the
|
||||||
whose database the scenario may change: MM_DB, see lists.py): the wells table (stacked
|
scenario may change: MM_DB, see lists.py): the well cards (a card per well, the rating
|
||||||
drill bars, Manage on every row, the rating chip), drills that get nothing (amber dashed
|
chip), drills that get nothing (amber dashed bars on their slots; in the aside the NOTHING
|
||||||
bars, the NOTHING LEFT tag, a greyed picture), a tapped-out well's panel, and the moon
|
LEFT tag, a greyed picture), a tapped-out well, and the moon sale opened (its lines scroll
|
||||||
sale opened (its lines scroll with the moon). Args: an optional name prefix."""
|
with the moon). Args: an optional name prefix."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -19,13 +19,13 @@ def run(game, args):
|
|||||||
game.idle()
|
game.idle()
|
||||||
game.shot(pre + "table", settle=0.8)
|
game.shot(pre + "table", settle=0.8)
|
||||||
ids = [e["id"] for e in game.elements()]
|
ids = [e["id"] for e in game.elements()]
|
||||||
wells = [w for w in fx["wells"] if ("manage:" + w) in ids]
|
wells = [w for w in fx["wells"] if ("well:" + w) in ids]
|
||||||
game.expect(len(wells) == len(fx["wells"]), "Manage on every well row (%d of %d)" % (len(wells), len(fx["wells"])))
|
game.expect(len(wells) == len(fx["wells"]), "a card for every well (%d of %d)" % (len(wells), len(fx["wells"])))
|
||||||
|
|
||||||
# Drills that get nothing: six more on a well, then the well holds almost nothing.
|
# Drills that get nothing: six more on a well, then the well holds almost nothing.
|
||||||
well = fx["wells"][1 % len(fx["wells"])]
|
well = fx["wells"][1 % len(fx["wells"])]
|
||||||
for _ in range(6):
|
for _ in range(6):
|
||||||
admin(game, "tool", item=2, tier=0)
|
admin(game, "tool", item=2, **{"yield": 0, "cycle": 0})
|
||||||
st = call(game, "GET", "/api/state")["state"]
|
st = call(game, "GET", "/api/state")["state"]
|
||||||
idle = [t["id"] for t in st["tools"] if t["storeItemId"] == 2 and not t.get("stakeId")][:6]
|
idle = [t["id"] for t in st["tools"] if t["storeItemId"] == 2 and not t.get("stakeId")][:6]
|
||||||
for tid in idle:
|
for tid in idle:
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Parity polish (needs --fixture). Phases (args, default all but ready):
|
"""Parity polish (needs --fixture). Phases (args, default all but ready):
|
||||||
bars: six more drills on a well: the wells table's bars with "+N" and the multi-well
|
bars: six more drills on a well: their slots on the well's card, the multi-well card,
|
||||||
diamond caps, the well panel in the web's order (deploy, drills, then the claims).
|
the well panel's drill lines (it only shows: the actions are on the cards).
|
||||||
ready: run with --config mm.fake_latency_ms=2500: drills finish while the refetch is
|
ready: run with --config mm.fake_latency_ms=2500: drills finish while the refetch is
|
||||||
held back; the header badge, Claim all, moon row badge and Claim buttons count up on
|
held back; the header badge, Claim all, moon row badge and Claim buttons count up on
|
||||||
our clock ("Claim N drills", "Claim <amount>").
|
our clock ("Claim N drills", "Claim <amount>").
|
||||||
motion: the header's reduced-motion toggle (run with --config mm.reduced_motion=0: forced,
|
motion: the header's reduced-motion toggle (run with --config mm.reduced_motion=0: forced,
|
||||||
so nothing is saved), the still ticker, a reduced reveal.
|
so nothing is saved), the still ticker, a reduced reveal.
|
||||||
links: "N idle <tool>" opens that copy, "Better" well link, market card hover, refinery
|
links: "N idle <tool>" opens that copy, market card hover, refinery recipes (the
|
||||||
recipes."""
|
"Better" well is plain text in the well panel now: its link shot is skipped)."""
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -47,7 +47,7 @@ def idle_tools(game, item):
|
|||||||
|
|
||||||
def stake_many(game, item, n, well):
|
def stake_many(game, item, n, well):
|
||||||
for _ in range(n):
|
for _ in range(n):
|
||||||
admin(game, "tool", item=item, tier=0)
|
admin(game, "tool", item=item, **{"yield": 0, "cycle": 0})
|
||||||
for tid in idle_tools(game, item)[:n]:
|
for tid in idle_tools(game, item)[:n]:
|
||||||
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
|
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
|
||||||
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
|
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
|
||||||
@@ -84,7 +84,7 @@ def ready(game):
|
|||||||
free = [w]
|
free = [w]
|
||||||
well = free[0]["id"]
|
well = free[0]["id"]
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
admin(game, "tool", item=3, tier=0)
|
admin(game, "tool", item=3, **{"yield": 0, "cycle": 0})
|
||||||
copies = idle_tools(game, 3)[:2]
|
copies = idle_tools(game, 3)[:2]
|
||||||
for tid in copies:
|
for tid in copies:
|
||||||
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
|
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
|
||||||
@@ -143,7 +143,7 @@ def links(game):
|
|||||||
fx = game.fixture
|
fx = game.fixture
|
||||||
# Two LunarLite L100 (10 s cycles), one mining: its panel links to the idle one.
|
# Two LunarLite L100 (10 s cycles), one mining: its panel links to the idle one.
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
admin(game, "tool", item=3, tier=0)
|
admin(game, "tool", item=3, **{"yield": 0, "cycle": 0})
|
||||||
copies = idle_tools(game, 3)
|
copies = idle_tools(game, 3)
|
||||||
r = call(game, "POST", "/api/act/stake", {"tool": copies[0], "target": fx["wells"][0]})
|
r = call(game, "POST", "/api/act/stake", {"tool": copies[0], "target": fx["wells"][0]})
|
||||||
game.expect(r.get("ok"), "stake L100: %s" % r.get("error"))
|
game.expect(r.get("ok"), "stake L100: %s" % r.get("error"))
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Sells ask in place, not in a dialog (needs --fixture; cards.ask_button): the drill
|
"""Sells ask in place, not in a dialog (needs --fixture; cards.ask_button): the drill
|
||||||
panel's spare attachment Sell turns into Cancel on its spot with Confirm just left of it and
|
panel's Sell (an idle copy) turns into Cancel on its spot with Confirm just left of it and
|
||||||
the server's question where the text was; no dialog opens; nothing else moves. It cancels
|
the server's question where the text was; no dialog opens; nothing else moves. It cancels
|
||||||
on Esc, on Cancel, on a click elsewhere and after ASK_IDLE seconds; Enter confirms (Confirm
|
on Esc, on Cancel, on a click elsewhere and after ASK_IDLE seconds; Enter confirms (Confirm
|
||||||
has the soft focus) and sells. Then the drill's own Sell (asks, cancelled) and the moon's
|
has the soft focus) and sells the drill. Then the moon's Sell moon (shot of its asking state, when the moon can be sold). Shots of each."""
|
||||||
Sell moon (shot of its asking state, when the moon can be sold). Shots of each."""
|
|
||||||
|
|
||||||
|
|
||||||
def rect_of(game, id_):
|
def rect_of(game, id_):
|
||||||
@@ -49,9 +48,9 @@ def covers(outer, inner):
|
|||||||
def run(game, args):
|
def run(game, args):
|
||||||
fx = game.fixture
|
fx = game.fixture
|
||||||
pre = (args[0] + "-") if args else ""
|
pre = (args[0] + "-") if args else ""
|
||||||
sell = "confirm:sell-attachment"
|
sell = "confirm:sell-tool"
|
||||||
game.idle()
|
game.idle()
|
||||||
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
|
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
|
||||||
game.idle()
|
game.idle()
|
||||||
game.scroll_to(sell)
|
game.scroll_to(sell)
|
||||||
before = rect_of(game, sell)
|
before = rect_of(game, sell)
|
||||||
@@ -95,16 +94,6 @@ def run(game, args):
|
|||||||
game.expect(game.log_has(r"mm: toast Sold"), "Enter sells (a toast)")
|
game.expect(game.log_has(r"mm: toast Sold"), "Enter sells (a toast)")
|
||||||
game.shot(pre + "sellask-sold", settle=0.4)
|
game.shot(pre + "sellask-sold", settle=0.4)
|
||||||
|
|
||||||
# The drill's own Sell: asks, a shot, cancelled.
|
|
||||||
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
|
|
||||||
game.idle()
|
|
||||||
if rect_of(game, "confirm:sell-tool"):
|
|
||||||
ask(game, "confirm:sell-tool")
|
|
||||||
game.shot(pre + "sellask-tool", settle=0.3)
|
|
||||||
game.key("key_esc")
|
|
||||||
game.wait_for("g.ask == nil", 5)
|
|
||||||
game.idle()
|
|
||||||
|
|
||||||
# The moon's Sell moon (when it can be sold yet).
|
# The moon's Sell moon (when it can be sold yet).
|
||||||
game.navigate("{section='moons', moon='%s'}" % fx["moon2"])
|
game.navigate("{section='moons', moon='%s'}" % fx["moon2"])
|
||||||
game.idle()
|
game.idle()
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""The settings menu's rows take real mouse clicks (xdotool), also where the menu lies over
|
||||||
|
the moon viewport (its drag used to take the press)."""
|
||||||
|
|
||||||
|
|
||||||
|
def run(game, args):
|
||||||
|
game.idle()
|
||||||
|
checks = [
|
||||||
|
("mute", "tostring(require('modules.sfx').muted())"),
|
||||||
|
("theme", "tostring(require('modules.theme').light)"),
|
||||||
|
("motion", "tostring(require('modules.anim').reduced)"),
|
||||||
|
("dialog:fullscreen", "tostring(mmwindow.is_fullscreen())"),
|
||||||
|
("menu:save-code", "tostring(g.dialog:is_open())"),
|
||||||
|
]
|
||||||
|
for id_, expr in checks:
|
||||||
|
game.os_click("settings")
|
||||||
|
game.wait_for("g.dialog:is_open()", timeout=5)
|
||||||
|
game.sleep(0.8)
|
||||||
|
before = game.eval(expr)
|
||||||
|
game.os_click(id_)
|
||||||
|
game.sleep(1.2)
|
||||||
|
after = game.eval(expr)
|
||||||
|
print(" %-18s %s -> %s dialog open: %s" % (id_, before, after, game.eval("tostring(g.dialog:is_open())")))
|
||||||
|
game.shot("after-" + id_.replace(":", "_"), settle=0.2)
|
||||||
|
game.key("key_esc")
|
||||||
|
game.sleep(0.6)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Narrow windows (run with --size 1024x640; needs --fixture): the aside gets its own row
|
"""Narrow windows (run with --size 1024x640; needs --fixture): the aside gets its own row
|
||||||
under the moon list and the main panel and the page scrolls to it (a well, the drill
|
under the moon list and the main panel and the page scrolls to it (the drill panel; a
|
||||||
panel); sections without the aside use the whole width; the banner wraps; keys scroll
|
well's card stays in view); sections without the aside use the whole width; the banner wraps; keys scroll
|
||||||
the page. Args: an optional name prefix."""
|
the page. Args: an optional name prefix."""
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +18,11 @@ def run(game, args):
|
|||||||
game.click("well:" + well)
|
game.click("well:" + well)
|
||||||
game.wait_for("view.well == '%s' and not busy" % well)
|
game.wait_for("view.well == '%s' and not busy" % well)
|
||||||
game.idle()
|
game.idle()
|
||||||
|
# (picking a well card keeps the cards in view: its actions are there)
|
||||||
|
game.expect((game.eval("g.scroll_pos.page or 0") or 0) < 1, "picking a well doesn't scroll the page")
|
||||||
|
game.shot(pre + "small-well-card", settle=0.8)
|
||||||
|
game.hover("tab:market")
|
||||||
|
game.key("key_end")
|
||||||
game.wait_for("(g.scroll_pos.page or 0) > 100", timeout=5)
|
game.wait_for("(g.scroll_pos.page or 0) > 100", timeout=5)
|
||||||
game.shot(pre + "small-well", settle=1.0)
|
game.shot(pre + "small-well", settle=1.0)
|
||||||
# (keys scroll the area under the mouse: the moon itself scrolls when it doesn't fit,
|
# (keys scroll the area under the mouse: the moon itself scrolls when it doesn't fit,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Server texts in the confirms (needs --fixture): selling a spare attachment asks in place
|
"""Server texts in the confirms (needs --fixture): selling an idle drill asks in place
|
||||||
(cards.ask_button), buy seat, buy refinery slot and start a new game open the server's
|
(cards.ask_button), buy seat, buy refinery slot and start a new game open the server's
|
||||||
confirm dialog (each cancelled with Esc, nothing bought); the theme toggle switches live.
|
confirm dialog (each cancelled with Esc, nothing bought); the theme toggle switches live.
|
||||||
Shots of each."""
|
Shots of each."""
|
||||||
@@ -44,9 +44,9 @@ def run(game, args):
|
|||||||
fx = game.fixture
|
fx = game.fixture
|
||||||
pre = (args[0] + "-") if args else ""
|
pre = (args[0] + "-") if args else ""
|
||||||
game.idle()
|
game.idle()
|
||||||
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
|
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
|
||||||
game.idle()
|
game.idle()
|
||||||
ask(game, "confirm:sell-attachment", pre + "texts-sell-attachment")
|
ask(game, "confirm:sell-tool", pre + "texts-sell-tool")
|
||||||
game.navigate("{section='advisors'}")
|
game.navigate("{section='advisors'}")
|
||||||
game.idle()
|
game.idle()
|
||||||
confirm(game, "confirm:buy-seat", pre + "texts-buy-seat")
|
confirm(game, "confirm:buy-seat", pre + "texts-buy-seat")
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Drill upgrade tracks (needs --fixture): the Equipment table's two track columns (yield and
|
||||||
|
cycle: value ▸ next, level pips, Upgrade or Max, the kept need line) and the
|
||||||
|
drill panel of the fixture's upgraded idle PD-1 (both boxes, both ladders, Sell drill).
|
||||||
|
Clicking each Upgrade twice in the panel keeps the buttons where they are (click_check).
|
||||||
|
Shots: tracks-equipment, tracks-panel, tracks-panel-after. Args: an optional name prefix."""
|
||||||
|
|
||||||
|
|
||||||
|
def ups(game):
|
||||||
|
return sorted(e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?"))
|
||||||
|
|
||||||
|
|
||||||
|
def run(game, args):
|
||||||
|
fx = game.fixture
|
||||||
|
pre = (args[0] + "-") if args else ""
|
||||||
|
game.idle()
|
||||||
|
game.navigate("{section='equipment'}")
|
||||||
|
game.idle()
|
||||||
|
game.shot(pre + "tracks-equipment", settle=1.0)
|
||||||
|
game.lint(pre + "tracks-equipment")
|
||||||
|
|
||||||
|
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
|
||||||
|
game.idle()
|
||||||
|
game.shot(pre + "tracks-panel", settle=1.0)
|
||||||
|
game.lint(pre + "tracks-panel")
|
||||||
|
tool = game.eval("g.state.equipment.drill.toolId")
|
||||||
|
for kind in ("yield", "cycle"):
|
||||||
|
ids = [i for i in ups(game) if ("tool=" + tool) in i and i.endswith("track=" + kind)]
|
||||||
|
game.expect(ids, "%s upgrade button in the panel: %s" % (kind, ups(game)))
|
||||||
|
for n in range(2):
|
||||||
|
ids = [i for i in ups(game) if ("tool=" + tool) in i and i.endswith("track=" + kind)]
|
||||||
|
if not ids:
|
||||||
|
break # (the top level: Max level in its place)
|
||||||
|
game.click_check(ids[-1], name=pre + "tracks-click-%s-%d" % (kind, n))
|
||||||
|
game.shot(pre + "tracks-panel-after", settle=1.0)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Well cards (needs --fixture and an MM_DEV=1 server: admin finish-stakes): every action
|
||||||
|
on a well is on its card in the main panel, the aside only shows. Buys a fresh moon, then:
|
||||||
|
the cards with their empty "+ Deploy" slots and the tray of idle drills (shot cards-empty);
|
||||||
|
a tray tile deploys straight to the selected well (cards-picked); the card's deploy slot
|
||||||
|
keeps its spot (click_check); a multi-well drill (the wide card); finishes the drills: the
|
||||||
|
ready slots, gold with Claim and Redeploy (cards-ready); Claim on a slot (click_check), the
|
||||||
|
idle slot with Redrill (cards-idle), Redrill; selling the moon is in the aside. The
|
||||||
|
aside, with a well and with none selected, has no buttons but Sell moon, the one destructive
|
||||||
|
action kept there (aside-well, aside-moon).
|
||||||
|
Args: an optional name prefix."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from lists import admin, call # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def aside_buttons(game):
|
||||||
|
"""Registered elements drawn inside the aside panel (below the header)."""
|
||||||
|
box = game.eval("(function() local p = g.aside_panel and g.aside_panel.root if not p then return nil end "
|
||||||
|
"local s = gui.get_screen_position(p) return {s.x, s.y} end)()")
|
||||||
|
if not box:
|
||||||
|
return []
|
||||||
|
left, top = box[0], box[1]
|
||||||
|
out = []
|
||||||
|
for e in game.elements():
|
||||||
|
r = e.get("rect")
|
||||||
|
if not r or not e.get("visible") or e.get("kind") == "wait":
|
||||||
|
continue
|
||||||
|
if r["x"] >= left - 1 and r["y"] <= top + 1 and not e["id"].startswith(("sell-moon", "moon-sale:")):
|
||||||
|
out.append(e["id"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def ids(game, prefix):
|
||||||
|
return [e["id"] for e in game.elements() if e["id"].startswith(prefix) and e.get("visible", True)]
|
||||||
|
|
||||||
|
|
||||||
|
def run(game, args):
|
||||||
|
pre = (args[0] + "-") if args else ""
|
||||||
|
game.idle()
|
||||||
|
admin(game, "credits", amount=100000, op="grant")
|
||||||
|
had = {m["id"] for m in call(game, "GET", "/api/state?catalog=0")["state"]["moons"]}
|
||||||
|
r = call(game, "POST", "/api/act/buy-moon", {})
|
||||||
|
game.expect(r.get("ok"), "buy a moon: %s" % r.get("error"))
|
||||||
|
st = call(game, "GET", "/api/state?catalog=0")["state"]
|
||||||
|
moon = next(m for m in st["moons"] if m["id"] not in had)
|
||||||
|
wells = [w["id"] for w in moon["wells"]]
|
||||||
|
game.navigate("{section='moons', moon='%s'}" % moon["id"])
|
||||||
|
game.idle()
|
||||||
|
game.wait_for("st.moon and st.moon.id == '%s'" % moon["id"], 10)
|
||||||
|
game.idle()
|
||||||
|
game.shot(pre + "cards-empty", settle=0.8)
|
||||||
|
game.lint(pre + "cards-empty")
|
||||||
|
|
||||||
|
# A tray tile deploys straight to the selected well (no picking): select the first well,
|
||||||
|
# click the lowest "stake" button there (the tray sits under the cards).
|
||||||
|
game.navigate("{section='moons', moon='%s', well='%s'}" % (moon["id"], wells[0]))
|
||||||
|
game.idle()
|
||||||
|
slot = "act:stake?target=" + wells[0]
|
||||||
|
btns = [e for e in game.elements() if e["id"].startswith(slot) and e.get("visible", True)]
|
||||||
|
game.expect(len(btns) > 1, "the tray's tiles deploy to the selected well")
|
||||||
|
n0 = game.eval("#st.stakes")
|
||||||
|
tile = max(btns, key=lambda e: e["rect"]["y"])["id"]
|
||||||
|
game.scroll_to(tile)
|
||||||
|
game.click(tile)
|
||||||
|
game.wait_for("#st.stakes > %d" % n0, 10)
|
||||||
|
game.idle()
|
||||||
|
game.shot(pre + "cards-picked", settle=0.5)
|
||||||
|
# The card's deploy slot (topmost) keeps its spot under repeated clicks.
|
||||||
|
top = min((e for e in game.elements() if e["id"].startswith(slot) and e.get("visible", True)),
|
||||||
|
key=lambda e: e["rect"]["y"], default=None)
|
||||||
|
if top:
|
||||||
|
game.scroll_to(top["id"])
|
||||||
|
game.click_check(top["id"], name=pre + "click-deploy")
|
||||||
|
game.idle()
|
||||||
|
|
||||||
|
# A multi-well drill (bought when none is idle).
|
||||||
|
st = call(game, "GET", "/api/state")["state"]
|
||||||
|
wide = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) > 1]
|
||||||
|
if not wide:
|
||||||
|
item = next((t for t in st["catalog"]["tools"] if (t.get("reach") or 1) > 1), None)
|
||||||
|
if item:
|
||||||
|
call(game, "POST", "/api/act/buy-tool", {"id": str(item["storeItemId"])})
|
||||||
|
st = call(game, "GET", "/api/state?catalog=0")["state"]
|
||||||
|
wide = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) > 1]
|
||||||
|
if wide:
|
||||||
|
r = call(game, "POST", "/api/act/stake", {"tool": wide[0]["id"], "target": wells[-1]})
|
||||||
|
game.expect(r.get("ok"), "stake a multi-well drill: %s" % r.get("error"))
|
||||||
|
# One more one-well drill on another well, so a card has two drill slots.
|
||||||
|
one = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) == 1]
|
||||||
|
if one and len(wells) > 1:
|
||||||
|
call(game, "POST", "/api/act/stake", {"tool": one[0]["id"], "target": wells[1]})
|
||||||
|
game.navigate("{section='moons', moon='%s', well='%s'}" % (moon["id"], wells[0]))
|
||||||
|
game.idle()
|
||||||
|
game.shot(pre + "cards-mining", settle=0.8)
|
||||||
|
game.lint(pre + "cards-mining")
|
||||||
|
left = aside_buttons(game)
|
||||||
|
game.expect(not left, "the aside (a well) has no buttons: %s" % left)
|
||||||
|
game.shot(pre + "aside-well", settle=0.3)
|
||||||
|
|
||||||
|
# Finish them: ready slots.
|
||||||
|
admin(game, "finish-stakes")
|
||||||
|
game.navigate("{section='moons', moon='%s'}" % moon["id"])
|
||||||
|
game.idle()
|
||||||
|
game.wait_for("(function() for _, w in ipairs(st.moon.wells) do if w.ready > 0 then return true end end end)()", 15)
|
||||||
|
game.idle()
|
||||||
|
game.shot(pre + "cards-ready", settle=0.8)
|
||||||
|
game.lint(pre + "cards-ready")
|
||||||
|
claims = [i for i in ids(game, "act:claim?claim-well=" + wells[0]) if "redeploy" not in i]
|
||||||
|
game.expect(len(claims) > 0, "a ready slot has Claim")
|
||||||
|
game.click_check(claims[0], name=pre + "click-claim")
|
||||||
|
game.wait_for("not g.busy and #g.pending == 0", 20)
|
||||||
|
game.idle()
|
||||||
|
game.shot(pre + "cards-idle", settle=0.8)
|
||||||
|
redrill = ids(game, "act:redrill?redrill-well=" + wells[0])
|
||||||
|
left_here = game.eval("(function() for _, w in ipairs(st.moon.wells) do if w.id == '%s' then return w.supply end end end)()" % wells[0])
|
||||||
|
remain = game.eval("(function() for _, w in ipairs(st.moon.wells) do if w.id == '%s' then return w.remain end end end)()" % wells[0])
|
||||||
|
# (none while the drills on it take all it has: the server would refuse it)
|
||||||
|
game.expect(len(redrill) > 0 or not left_here or not remain, "the idle slot has Redrill (the well has %s left, %s free)" % (left_here, remain))
|
||||||
|
if redrill:
|
||||||
|
game.click(redrill[0])
|
||||||
|
game.wait_for("not g.busy and #g.pending == 0", 20)
|
||||||
|
game.idle()
|
||||||
|
game.expect(ids(game, "moon-sale:" + moon["id"]) or ids(game, "sell-moon"), "selling the moon is in the aside")
|
||||||
|
game.shot(pre + "cards-redrilled", settle=0.8)
|
||||||
|
left = aside_buttons(game)
|
||||||
|
game.expect(not left, "the aside (the moon) has no buttons: %s" % left)
|
||||||
|
game.shot(pre + "aside-moon", settle=0.3)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""The well panel scrolled down (needs --fixture): deploy cards with pictures, pips and
|
"""The well panel scrolled down (needs --fixture; it only shows, deploying is on the well
|
||||||
reach, drill lines with thumbnails and attachment tags, the drills heading's share note;
|
cards): drill lines with thumbnails and level tags, the drills heading's share note;
|
||||||
then the market (movers, quote values, Refined tag) and a past moon in the list."""
|
then the market (movers, quote values, Refined tag) and a past moon in the list."""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ if [ -z "${MM_IN_XVFB:-}" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
export LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-1}
|
export LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-1}
|
||||||
|
# The game's saved files (player ids, settings) go beside the screenshot, never into the
|
||||||
|
# player's own (~/.local/share/moonminers): mm.new_player=1 would clear their saved id.
|
||||||
|
export XDG_DATA_HOME=${MM_SHOT_DATA:-$(dirname "$out")/data}
|
||||||
|
mkdir -p "$XDG_DATA_HOME"
|
||||||
cd "$(dirname "$bin")"
|
cd "$(dirname "$bin")"
|
||||||
log=$(dirname "$out")/shot.log
|
log=$(dirname "$out")/shot.log
|
||||||
TIMEOUT=${TIMEOUT:-120}
|
TIMEOUT=${TIMEOUT:-120}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
# any build (no test hook needed), so an old bundle can be compared with a new one:
|
# any build (no test hook needed), so an old bundle can be compared with a new one:
|
||||||
# scripts/views.sh dist/baseline/MoonMiners/MoonMiners.x86_64 dist/compare/before fx.json
|
# scripts/views.sh dist/baseline/MoonMiners/MoonMiners.x86_64 dist/compare/before fx.json
|
||||||
# scripts/views.sh dist/x86_64-linux/MoonMiners/MoonMiners.x86_64 dist/compare/after fx.json
|
# scripts/views.sh dist/x86_64-linux/MoonMiners/MoonMiners.x86_64 dist/compare/after fx.json
|
||||||
# API base: MM_API (default http://localhost:3000).
|
# API base: MM_API (required: a test server, no default).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
bin=$1; out=$2; fx=$3; size=${4:-1280x800}
|
bin=$1; out=$2; fx=$3; size=${4:-1280x800}
|
||||||
api=${MM_API:-http://localhost:3000}
|
api=${MM_API:?views.sh: set MM_API to a test server (no default)}
|
||||||
get() { python3 -c "import json,sys;print(json.load(open('$fx'))['$1'])"; }
|
get() { python3 -c "import json,sys;print(json.load(open('$fx'))['$1'])"; }
|
||||||
player=$(get player); moon=$(get moon); well=$(get well); drill=$(get drill_mining)
|
player=$(get player); moon=$(get moon); well=$(get well); drill=$(get drill_mining)
|
||||||
settle=4; [ "$size" != 1280x800 ] && settle=15
|
settle=4; [ "$size" != 1280x800 ] && settle=15
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
<<: *db-env
|
<<: *db-env
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||||
TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For
|
TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: moonminers
|
POSTGRES_DB: moonminers
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "127.0.0.1:5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -22,6 +22,8 @@ die() { echo "wt: $*" >&2; exit 1; }
|
|||||||
task_vars() {
|
task_vars() {
|
||||||
TASK=${1:-}
|
TASK=${1:-}
|
||||||
[[ $TASK =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "task name: lowercase letters, digits and -"
|
[[ $TASK =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "task name: lowercase letters, digits and -"
|
||||||
|
# mm_<task> must fit Postgres's 63-byte names (longer ones are cut short silently).
|
||||||
|
((${#TASK} <= 60)) || die "task name: at most 60 characters"
|
||||||
WT=$WS/wt-$TASK
|
WT=$WS/wt-$TASK
|
||||||
DB=mm_${TASK//-/_}
|
DB=mm_${TASK//-/_}
|
||||||
ST=$WT/.wt
|
ST=$WT/.wt
|
||||||
@@ -63,7 +65,8 @@ start_server() {
|
|||||||
{ tail -5 "$ST/migrate.log"; die "migrate failed (log $ST/migrate.log)"; }
|
{ tail -5 "$ST/migrate.log"; die "migrate failed (log $ST/migrate.log)"; }
|
||||||
rm -f "$ST/pid"
|
rm -f "$ST/pid"
|
||||||
# Its own session (not killed with the caller's), pid written by the process itself.
|
# Its own session (not killed with the caller's), pid written by the process itself.
|
||||||
(cd "$WT/server" && DB_DATABASE=$DB PORT=$port MM_DEV=1 PIDFILE=$ST/pid \
|
# On 127.0.0.1 only: MM_DEV=1 has no admin login.
|
||||||
|
(cd "$WT/server" && DB_DATABASE=$DB HOST=127.0.0.1 PORT=$port MM_DEV=1 PIDFILE=$ST/pid \
|
||||||
setsid -f bash -c 'echo $$ >"$PIDFILE"; exec bin/moonminers serve' \
|
setsid -f bash -c 'echo $$ >"$PIDFILE"; exec bin/moonminers serve' \
|
||||||
>"$ST/server.log" 2>&1 </dev/null)
|
>"$ST/server.log" 2>&1 </dev/null)
|
||||||
for _ in $(seq 25); do [[ -s $ST/pid ]] && break; sleep 0.1; done
|
for _ in $(seq 25); do [[ -s $ST/pid ]] && break; sleep 0.1; done
|
||||||
@@ -114,7 +117,14 @@ up() {
|
|||||||
local s=$WT/server ms=$MAIN/server
|
local s=$WT/server ms=$MAIN/server
|
||||||
[[ -e $s/web/static/css/app.css || ! -e $ms/web/static/css/app.css ]] || { mkdir -p "$s/web/static/css"; cp "$ms/web/static/css/app.css" "$s/web/static/css/"; }
|
[[ -e $s/web/static/css/app.css || ! -e $ms/web/static/css/app.css ]] || { mkdir -p "$s/web/static/css"; cp "$ms/web/static/css/app.css" "$s/web/static/css/"; }
|
||||||
[[ -e $s/web/static/app || ! -d $ms/web/static/app ]] || cp -a "$ms/web/static/app" "$s/web/static/app"
|
[[ -e $s/web/static/app || ! -d $ms/web/static/app ]] || cp -a "$ms/web/static/app" "$s/web/static/app"
|
||||||
|
# The copied .env names this worktree's own database, so anything run here (migrate,
|
||||||
|
# tests, the server) reaches mm_<task>, never the main checkout's.
|
||||||
[[ -e $WT/.env ]] || cp "$MAIN/.env" "$WT/.env"
|
[[ -e $WT/.env ]] || cp "$MAIN/.env" "$WT/.env"
|
||||||
|
if grep -q '^DB_DATABASE=' "$WT/.env"; then
|
||||||
|
sed -i "s/^DB_DATABASE=.*/DB_DATABASE=$DB/" "$WT/.env"
|
||||||
|
else
|
||||||
|
echo "DB_DATABASE=$DB" >>"$WT/.env"
|
||||||
|
fi
|
||||||
echo "$DB" >"$ST/db"
|
echo "$DB" >"$ST/db"
|
||||||
if [[ -z $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then
|
if [[ -z $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then
|
||||||
admin_sql "create database $DB"
|
admin_sql "create database $DB"
|
||||||
@@ -126,7 +136,7 @@ up() {
|
|||||||
start_server
|
start_server
|
||||||
fi
|
fi
|
||||||
echo "worktree $WT"
|
echo "worktree $WT"
|
||||||
echo "MM_API=http://localhost:$(cat "$ST/port")"
|
echo "MM_API=http://127.0.0.1:$(cat "$ST/port")"
|
||||||
echo "stop: $0 down $TASK (or rm $TASK to also remove the worktree)"
|
echo "stop: $0 down $TASK (or rm $TASK to also remove the worktree)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +189,7 @@ case ${1:-} in
|
|||||||
up) up "${2:-}" ;;
|
up) up "${2:-}" ;;
|
||||||
down) down "${2:-}" ;;
|
down) down "${2:-}" ;;
|
||||||
rm) remove "${2:-}" ;;
|
rm) remove "${2:-}" ;;
|
||||||
restart) task_vars "${2:-}"; stop_server; start_server; echo "MM_API=http://localhost:$(cat "$ST/port")" ;;
|
restart) task_vars "${2:-}"; stop_server; start_server; echo "MM_API=http://127.0.0.1:$(cat "$ST/port")" ;;
|
||||||
status) status ;;
|
status) status ;;
|
||||||
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
|
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
@@ -5,6 +5,4 @@ node_modules
|
|||||||
.env*
|
.env*
|
||||||
.vscode
|
.vscode
|
||||||
.DS_Store
|
.DS_Store
|
||||||
web/static/css/app.css
|
|
||||||
art
|
art
|
||||||
web/static/app
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
# Build output
|
# Build output
|
||||||
/bin/
|
/bin/
|
||||||
/moonminers
|
/moonminers
|
||||||
/web/static/css/app.css
|
|
||||||
/web/static/app/
|
|
||||||
|
|
||||||
# Local env files (never commit)
|
# Local env files (never commit)
|
||||||
.env
|
.env
|
||||||
@@ -16,4 +14,3 @@ sql/queries.sql
|
|||||||
# Generated by the art scripts
|
# Generated by the art scripts
|
||||||
/art/equipment/build/
|
/art/equipment/build/
|
||||||
/art/advisors/build/
|
/art/advisors/build/
|
||||||
/art/attachments/build/
|
|
||||||
+103
-117
@@ -1,22 +1,17 @@
|
|||||||
# AGENTS.md — Moon Miners server
|
# AGENTS.md — Moon Miners server
|
||||||
|
|
||||||
The Go server and the web client of Moon Miners. What the game is, the repo layout and the
|
The Go server of Moon Miners: game rules, the JSON API the Defold client plays on, and the
|
||||||
shared rules: `../AGENTS.md`. Run every command below from this directory (`server/`).
|
admin pages. What the game is, the repo layout and the shared rules: `../AGENTS.md`. Run
|
||||||
|
every command below from this directory (`server/`).
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
|
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
|
||||||
package in `server/`; templates, static files and SQL are embedded with `go:embed`.
|
package in `server/`; 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
|
- The game's only client is the Defold one (`../client`), on the JSON API (`api.go`,
|
||||||
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
|
`api.md`). `GET /` redirects to `/utils` (a Defold web build will be served there later).
|
||||||
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
|
- The admin pages (`/utils/*`) are server-rendered HTML: Go templates, htmx (vendored in
|
||||||
- Experiment: a second web client at `/app` (`web/app/`), Preact + JSX + `@preact/signals`
|
`web/static/vendor`), plain CSS in `web/static/css/admin.css` (no build step, dark and
|
||||||
(vendored in `web/app/vendor`, no other libraries, no preact/compat), bundled by esbuild
|
light themes). No bundler, no TypeScript.
|
||||||
(standalone binary fetched like Tailwind, `make app`) into `web/static/app/` (built, not
|
|
||||||
committed). See "web/app" below.
|
|
||||||
- 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.
|
- Postgres 16, stock image. No functions or extensions in the database.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@@ -30,26 +25,30 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
|
|||||||
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
|
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
|
||||||
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they can't be sold. The Past tab loads them 10 at a
|
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they can't be sold. The Past tab loads them 10 at a
|
||||||
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
|
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
|
||||||
`loadView` (state, the past page, the resolved view).
|
`loadView` (state, the past page, the resolved view). `loadState` leaves moon
|
||||||
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
|
descriptions out (`apiPageFor` loads the viewed moon's, `loadMoonDescription`);
|
||||||
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
|
`loadConfig` (resources, store items) is cached per config snapshot.
|
||||||
|
- `views.go`: the `View` (section, moon, well, tab; the API's view params `s=&moon=&well=`),
|
||||||
|
the `Page` view model, formatting helpers, the admin templates' functions.
|
||||||
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
|
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
|
||||||
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
|
limits with forms parsed up front, a per-IP limit on new players, IPv6 by its /64), `runAction` (every game
|
||||||
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
|
action).
|
||||||
action and responds the same way.
|
- `admin.go`: the admin login (`ADMIN_PASSWORD`, `/utils/login`, `requireAdmin` around every
|
||||||
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
|
other `/utils` route, in-memory sessions in the `mm_admin` cookie; none with `MM_DEV=1`).
|
||||||
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
|
`admin_test.go`: the gate.
|
||||||
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422).
|
- `api.go`, `api.md`: the JSON API: `GET /api/state` (the view models mapped to JSON DTOs;
|
||||||
|
times in unix ms) and `POST /api/act/<action>` (`runAction`, 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).
|
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,
|
`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
|
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,
|
sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
|
||||||
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or
|
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:
|
`include=`; reasons are codes with the player-facing 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
|
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
|
||||||
for every test view.
|
for every test view (the test states are in `templates_test.go`).
|
||||||
- `config/game.json`: the game config (starting credits/resources/tools, `moonGrades`,
|
- `config/game.json`: the game config (starting credits/resources/tools, `moonGrades`,
|
||||||
resource prices, store items with upgrade tiers, moon types: a name, a placeholder
|
resource prices, store items with their two upgrade tracks, moon types: a name, a placeholder
|
||||||
description and resource `affinities`, refinery), embedded. `moonGrades` (D, C, B, A, S,
|
description and resource `affinities`, refinery), embedded. `moonGrades` (D, C, B, A, S,
|
||||||
cheapest first) are moon vendors: a price, `wellCountWeights`, a well supply range and
|
cheapest first) are moon vendors: a price, `wellCountWeights`, a well supply range and
|
||||||
affinities that multiply the type's. The grade is `staking_source.grade` (migration 018;
|
affinities that multiply the type's. The grade is `staking_source.grade` (migration 018;
|
||||||
@@ -58,13 +57,13 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
|
|||||||
count by its grade's weights, resources by the affinities, each well's supply in the
|
count by its grade's weights, resources by the affinities, each well's supply in the
|
||||||
grade's [`wellSupplyMin`, `wellSupplyMax`); a first moon always has every raw input of a
|
grade's [`wellSupplyMin`, `wellSupplyMax`); a first moon always has every raw input of a
|
||||||
recipe. Ratings, reveal bars, resale and descriptions compare a moon with its grade.
|
recipe. Ratings, reveal bars, resale and descriptions compare a moon with its grade.
|
||||||
Upgrade tiers set a cycle `speed` (read from cfg(), not stored; `StoreItem.ToolSecs`). A moon's name comes from `moonNames` (roots and
|
Track levels set a tool's yield and cycle (read from cfg(), not stored; `StoreItem.ToolYield`, `ToolSecs`). A moon's name comes from `moonNames` (roots and
|
||||||
suffix style weights, `rollMoonName`), never repeated among a player's moons; its type is
|
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).
|
`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`
|
`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
|
(needed for the inventory foreign key and the SQL in game.go; the tracks aren't stored). Admins override its
|
||||||
values live on `/utils/economy` (table editor in `economy.js`; `economy.go`: a patch per
|
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
|
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim; `progression.go`: the Analysis tab's charts, saved config against the draft: payback per purchase, the clicker sim timeline (on demand, `POST /utils/economy/sim`), curves; drawn as SVG by `chart.go`). Read config values through
|
||||||
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
|
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
|
||||||
- `savecode.go`: save codes (a player's way back from another device; `GET /api/save-code`,
|
- `savecode.go`: save codes (a player's way back from another device; `GET /api/save-code`,
|
||||||
actions `save-code` and `restore` handled in `handleAPIAction`, not `runAction`; table
|
actions `save-code` and `restore` handled in `handleAPIAction`, not `runAction`; table
|
||||||
@@ -78,112 +77,100 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
|
|||||||
recent sales of the item, in base-price credits, decaying with a half-life, table
|
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
|
`market_pressure`; the factor eases from 1 towards `floor`, and a sale is priced at its
|
||||||
average over the pressure it adds). Personal modifiers never change the quote, cards,
|
average over the pressure it adds). Personal modifiers never change the quote, cards,
|
||||||
ticker or 24h history (recomputed, no table); the sell ticket shows them as lines ("+8%
|
ticker or 24h history (recomputed, no table; each item's 24h series is cached for the
|
||||||
|
current tick and config snapshot, `marketSeriesAt`, shared by every request; live events
|
||||||
|
are applied on top per request); the sell ticket shows them as lines ("+8%
|
||||||
for you", "−4% from your recent selling"). View model for the Market section (ticker,
|
for you", "−4% from your recent selling"). View model for the Market section (ticker,
|
||||||
watchlist, chart, sell ticket; `pick=` in the URL selects a quote).
|
watchlist, chart, sell ticket; `pick=` 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
|
- `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
|
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
|
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
|
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`.
|
drill on it is running; finished drills are claimed in the sale.
|
||||||
Also selling tools and spare attachments for `equipmentResale.factor` (below 1) of their
|
Also selling tools for `equipmentResale.factor` (below 1) of their credit price (price +
|
||||||
credit price (a tool: price + its upgrades; refined parts not refunded): only idle
|
its track levels; refined parts not refunded): only idle tools with nothing to claim,
|
||||||
tools with nothing to claim, never the last tool; attachments on it return to the inventory.
|
never the last tool.
|
||||||
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on upgrade tiers), rules
|
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on track levels), rules
|
||||||
(start batch, collect, buy slot, batch size upgrade, sell refined, upgrade parts) and its
|
(start batch, collect, buy slot, batch size upgrade, sell refined, upgrade parts) and its
|
||||||
view model. A batch of n takes `seconds` × min(n, 1 + `bulkLog` × ln n) (`batchSecs`): logarithmic. The most per
|
view model. A batch of n takes `seconds` × min(n, 1 + `bulkLog` × ln n) (`batchSecs`): logarithmic. The most per
|
||||||
batch is `maxBatch`, raised for the whole refinery (every slot) by `batchUpgrades`
|
batch is `maxBatch`, raised for the whole refinery (every slot) by `batchUpgrades`
|
||||||
(action `upgrade-batch`, `users.refinery_batch_level`). Tables
|
(action `upgrade-batch`, `users.refinery_batch_level`). Tables
|
||||||
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
|
`refined_account`, `refinery_job`, `users.refinery_slots`.
|
||||||
- `bonus.go`: player bonuses. `bonusesFor` combines advisors and a tool's attachments
|
- `bonus.go`: player bonuses. `bonusesFor` combines the seated advisors (multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
|
||||||
(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 (cycles to a tenth of a second, at least 1 s) (used by
|
||||||
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`.
|
`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).
|
Bonuses are fixed when the action runs (a stake keeps its duration and amount).
|
||||||
- `attachments.go`: attachments config (`attachments` in game.json: `slotsPerTool`, items
|
- `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`,
|
||||||
with `speed`/`yield` multipliers), buy/equip/unequip rules (one of each per tool; equip
|
`tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds
|
||||||
and unequip any time, they count from the next stake), store cards and the drill panel
|
flat, with a price and optional refined `parts` (none in game.json now: credits only). Levels are per tool
|
||||||
(`drill=` in the URL, Equipment section, in the aside). Table `attachment`
|
(`inventory_item.yield_level`, `cycle_level`; copies with the same levels are
|
||||||
(`inventory_item_id` null: unequipped).
|
interchangeable, `pickTool`); action `upgrade` (`track=yield|cycle`), counted from the next
|
||||||
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
|
stake. Reach is the item's and never changes. Also the view models (`TrackRow` with its
|
||||||
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
|
ladder, on the Equipment card and in the drill panel: `drill=` in the view, Equipment
|
||||||
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
|
section, in the aside; the toast text).
|
||||||
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
|
- `advisors.go`: advisors config (`advisors`), hire, buy seat, seat/unseat/swap/dismiss
|
||||||
|
rules (only seated advisors give a bonus; dismissing refunds nothing), Advisors section
|
||||||
|
and the advisor panel (`adv=` in the view, in the aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
|
||||||
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
|
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
|
||||||
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
|
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
|
||||||
`all` for players from before it). New players see only the first moon purchase; deploying
|
`all` for players from before it). New players see only the first moon purchase; deploying
|
||||||
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
|
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
|
||||||
attachments, moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
|
moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
|
||||||
tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
|
tool Advisors. The API sends `unlocked`; `View.resolve` sends locked sections to the moon
|
||||||
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
|
view and `runAction` refuses locked actions (`actionGates`). A revealed tab shines until
|
||||||
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
|
opened (`markSeen` in `handleAPIState`). New sections and actions need a gate there.
|
||||||
sections and actions need a gate there.
|
|
||||||
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
|
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
|
||||||
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged as `admin_*` in
|
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged
|
||||||
`event_log`). In the game, admins (`Page.Admin`, `state.admin`) also get a quick grant
|
as `admin_*` in `event_log` with `"admin": "admin"`). In the game, API players in
|
||||||
dialog (header Grant button or G; action `admin-grant`, 403 for others). `notBanned` gives banned players (`users.banned_at`) a suspended page.
|
`ADMIN_USER_IDS` (`state.admin`) get a quick grant (action `admin-grant`, 403 for others).
|
||||||
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put
|
Banned players (`users.banned_at`) get a 403 from the API.
|
||||||
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`.
|
`web/templates/admin.html`: the page head (`head`, loads `admin.css`), the shared admin nav
|
||||||
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon
|
(`{{template "admin-nav" "<page>"}}`, put it on every `/utils` page; it has the logout),
|
||||||
list, sections, side panel).
|
the confirm dialog, the login page. `/utils` redirects to `/utils/players`.
|
||||||
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's
|
- `web/static/js/`: the admin pages' JS. `tabs.js`: sliding tab highlight (`data-tabs`).
|
||||||
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`)
|
`enter.js`: entrance animations. `ui.js`: the custom confirm dialog (`hx-confirm`) and
|
||||||
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene.
|
toasts. `liveops.js`: wires the dialog on the htmx pages. `economy.js`: the economy editor.
|
||||||
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view
|
- `web/static/assets/`: tool and advisor images (the API's catalog image URLs).
|
||||||
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game
|
- `moons.go`: moon settings endpoints (`/api/moon-settings`; saving: admin session, an
|
||||||
and admin pages.
|
`ADMIN_USER_IDS` player or `MM_DEV`), `isAdmin`. `moondesc.go`: moon descriptions
|
||||||
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound
|
generated from the look (mirrors the Defold client's `modules/moon/terrain.lua`, `traits`;
|
||||||
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored
|
`go run . moon-traits` prints Go's side for comparing; nothing checks them automatically
|
||||||
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for
|
any more) and the wells.
|
||||||
outside control (e.g. ads).
|
- `reveal.go`: the new-moon section (`s=new-moon`, a teaser with a shrouded moon, `mystery`
|
||||||
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and
|
in the scene data) and the reveal after `buy-moon` (the act response's `reveal`).
|
||||||
`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
|
`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
|
sample of `rollWells` (resampled when the config changes), stored in
|
||||||
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
|
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
|
||||||
and the star chip in the moon list and header.
|
and the star chip in the moon list and header.
|
||||||
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
|
- `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
|
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
|
||||||
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
|
via `eventMultiplier` in game.go, `announcement`; both in the API's `banner`).
|
||||||
- `scripts/tool-art.mjs`: generated tool art per tier, not in use (its output was removed;
|
- `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in
|
||||||
the tools show their original images). `StoreItem.ImageAt` uses `<image>-tN` from
|
use; the tools show their original images.
|
||||||
`web/static/assets/items` when it exists, else the base image.
|
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–022):
|
||||||
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
|
|
||||||
- `web/app/` (paused experiment: the Preact client, `GET /app`, template `spa.html`): the
|
|
||||||
Moons slice on the JSON API; other sections link to the classic client. `session.js` is
|
|
||||||
like the Defold client's session (`base`, `pending`, outbox with `clientSeq`, rollback on
|
|
||||||
422), `predict.js` a port of the Defold `predict.lua`. Details in each file's header.
|
|
||||||
Rules: pass signals to JSX as they are; read `.value` only in logic, `computed()` and
|
|
||||||
effects (`scripts/check-signals-jsx.mjs` in `make check`). Key list items by stable ids
|
|
||||||
(drills by tool id, not stake id); never swap an element that animates for another.
|
|
||||||
Animate with transform/opacity (CSS, Web Animations), FLIP (`flip.js`) for rows that move.
|
|
||||||
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–019):
|
|
||||||
changes for existing ones, applied on every `migrate`, so each must be idempotent.
|
changes for existing ones, applied on every `migrate`, so each must be idempotent.
|
||||||
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
|
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
|
||||||
- `art/`: sources of the store, attachment and advisor images (Blender scripts, prompts);
|
- `art/`: sources of the store and advisor images (Blender scripts, prompts);
|
||||||
they write into `web/static/assets`.
|
they write into `web/static/assets`.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind and
|
- `make dev`: server on :3000 (`MM_DEV=1`: no admin login) that reloads templates/static
|
||||||
esbuild (the Preact client) watching. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every
|
from disk. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every game API request that
|
||||||
game request that long, to try both clients on a slow connection.
|
long, to try the client on a slow connection.
|
||||||
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`,
|
- `make build`, `make check` (gofmt, vet, template, API and admin gate tests, JS syntax of
|
||||||
moon traits check, the Preact client's `.value` check and build), `make migrate`,
|
every `web/static/js/*.js`), `make migrate`, `make load-config`. `go run . sim` prints the
|
||||||
`make load-config`. `go run . sim` prints the economy analysis.
|
economy analysis.
|
||||||
- Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the
|
- 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;
|
admin templates like the server and renders every page against in-memory data; when you
|
||||||
when you add a page, section or view model branch, make sure its states/views reach it.
|
add a page or a branch, make sure its data reaches it.
|
||||||
- Local DB: `podman-compose up -d db` (from the repo root) then `make migrate` (see README).
|
- Local DB: `podman-compose up -d db` (from the repo root) then `make migrate` (see README).
|
||||||
Agents: `../scripts/wt up <task>` gives a worktree its own database and server instead.
|
Agents: `../scripts/wt up <task>` gives a worktree its own database and server instead.
|
||||||
There is no other test suite, except `wells_test.go` (`go test -run Wells -v`: well roll
|
Other tests: `go test ./...` (game rules on in-memory states, no database);
|
||||||
distribution, value by well count, resale odds, sim seeds).
|
`wells_test.go` (`go test -run Wells -v`: well roll distribution, value by well count,
|
||||||
|
resale odds, sim seeds). `claim_db_test.go` needs a database of its own
|
||||||
|
(`MM_TEST_DATABASE=mm_<task>`, from `scripts/wt`; skipped without it): `TestClaimGolden`
|
||||||
|
compares claim-all's results with `testdata/claim_golden.txt` (`MM_CLAIM_GOLDEN_WRITE=1`
|
||||||
|
rewrites it, only for a deliberate rule change), `TestClaimAllPerf -v` times it.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
- Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`;
|
- Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`;
|
||||||
@@ -199,23 +186,22 @@ distribution, value by well count, resale odds, sim seeds).
|
|||||||
Postgres credentials come from `.env` (in `server/`, else the repo root's `../.env`; only
|
Postgres credentials come from `.env` (in `server/`, else the repo root's `../.env`; only
|
||||||
the first one found is read): `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
|
the first one found is read): `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
|
||||||
`DB_DATABASE`, plus `ADMIN_DB_USER`/`ADMIN_DB_PASSWORD` for `migrate`. Never read, print or
|
`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`
|
commit `.env`. `ADMIN_PASSWORD`: the admin pages' password (unset: `/utils` is locked).
|
||||||
(unset: nobody); with `MM_DEV=1` everyone is an admin.
|
`ADMIN_USER_IDS` (comma-separated player ids): admins in the game API (`state.admin`,
|
||||||
|
`admin-grant`, saving moon settings). With `MM_DEV=1` there is no admin login and every
|
||||||
|
player is an admin.
|
||||||
|
`PORT` (default 3000) and `HOST` (unset: every interface; `scripts/wt` sets `127.0.0.1`).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
|
- 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
|
- New section: add it to `sections` and `State.page` (views.go) and map it in the API
|
||||||
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
|
(`addSection`). New action: a case in `server.runAction` (`/api/act/<action>`) and in
|
||||||
the address bar in sync.
|
`api.md`. The client sends the current view (s, moon, well, drill) with every action, so
|
||||||
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section`
|
action parameters must use other names.
|
||||||
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
|
- The server is the authority; clients predict only what `api.md` "Prediction" allows.
|
||||||
`/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
|
- New admin page: a route in `adminRoutes` (server.go; it's behind the login), a template
|
||||||
so action parameters must use other names. Give it a sound in fx.js if it needs one.
|
that starts with `{{template "head"}}` and has the admin nav, styles in `admin.css`.
|
||||||
- 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.
|
- SQL is lowercase with snake_case identifiers.
|
||||||
- When you add an endpoint, add it to `test-endpoints.restclient`.
|
- 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
|
- 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).
|
transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
|
||||||
- Dispose any three.js geometry and materials you create.
|
|
||||||
@@ -1,3 +1,75 @@
|
|||||||
|
# Wider steps (2026-10-22)
|
||||||
|
|
||||||
|
Playing the progression pass, the author had a LunarLite L100 (and money for more) about
|
||||||
|
33 minutes in, first C moon at 19 minutes: too fast. Their run's money: mostly raw sales
|
||||||
|
from five C moons, plus one Plasma Weave sale (₥32k, of which the refining margin was only
|
||||||
|
about ₥9k). The fix is a steeper grade ladder: each grade's price and wells, and its
|
||||||
|
drill's yield, price and upgrade prices, scaled by the same factor (C ×2, B ×3, A ×5,
|
||||||
|
S ×8), which keeps a moon's value (1.3–1.8× its price) and a drill's 4–5 claims per well on
|
||||||
|
its grade, while the jump to the next grade grows faster than income. D and the PD-1 are
|
||||||
|
unchanged. Refined items now gain about 20% over their inputs (was 40–100%).
|
||||||
|
|
||||||
|
Grades: D 300, C 12k, B 90k, A 1.5M, S 24M. Drills: PD-1 1,200 (8), PX-4 8,000 (120), L100
|
||||||
|
40,200 (900), L300 645k (20,000), LRE 9.44M (384k), EDE 17M (880k), LCE 24.8M (1.6M).
|
||||||
|
Sim, first moon of each grade: copies C 12m, B 30m, A 58m, S 2h33; boosts C 10m, B 21m,
|
||||||
|
A 40m, S 1h49 (was C 7m, B 15m, A 32m, S 1h04). A real player ran about 2.5× slower than
|
||||||
|
the sim's (C at 19m against the sim's 7m), so expect roughly C 30m, B 1h15 in play.
|
||||||
|
|
||||||
|
# Progression: each new drill rips through the current grade (2026-10-22)
|
||||||
|
|
||||||
|
The loop: on a grade, its drill needs about 3–6 claims per well; the next drill (bought
|
||||||
|
before the next grade, cheaper than that grade's moons) empties the current grade's wells
|
||||||
|
in about one claim; on the next grade it is back to 3–6. Each drill's base yield is about
|
||||||
|
its grade's average well ÷ 4.5 (multi-well drills × their reach): PD-1 8 (D), PX-4 60 (C),
|
||||||
|
L100 300 (B), L300 4,000 (A), LRE 48,000 (S); EDE 110,000 and LCE 200,000 rip S further
|
||||||
|
(more drills than grades until planets and stars). Sim, claims per average well on its own
|
||||||
|
grade: PD-1 4.3, PX-4 5.4, L100 4.5, L300 4.5, LRE 4.6, EDE 3.3, LCE 1.8.
|
||||||
|
|
||||||
|
Prices: each drill earns a bit more per credit than the one before on its grade (sim "a
|
||||||
|
drill alone on its grade", ₥/min per 1k₥: 981, 1.6k, 2k, 2.4k, 2.6k, 2.9k, 3.1k), else the
|
||||||
|
sim (and a player) just buys more of the cheapest drill: PD-1 1,200, PX-4 4,000, L100
|
||||||
|
13,400, L300 129,000, LRE 1.18M, EDE 2.12M, LCE 3.1M. Each upgrade level costs 0.95 × what
|
||||||
|
a copy would cost for the same added output (so upgrades are slightly better per credit).
|
||||||
|
|
||||||
|
Sim, first moon of each grade: copies C 7m, B 15m, A 32m, S 1h04; boosts C 6m, B 14m,
|
||||||
|
A 31m, S 49m (was about C 13m, B 22m, A 1h10, S 3h). Faster overall; the lever for a longer
|
||||||
|
game is moon and drill prices with wells scaled alike.
|
||||||
|
|
||||||
|
# Drill upgrade tracks (2026-10-22)
|
||||||
|
|
||||||
|
Attachments and the T1–T5 tier ladder are gone. Each drill has two tracks of its own
|
||||||
|
(config `storeItems[].tracks`), 4–5 levels each, every level a flat value: yield (claim per
|
||||||
|
cycle) and cycle (seconds, to a tenth). Reach never changes. Levels cost credits only (the refined parts they once took gated the
|
||||||
|
early game behind a refinery the player doesn't have yet; the code still takes `parts`).
|
||||||
|
Prices: level k of yield costs 0.97 × drill price × (Δclaim / base claim) × (base cycle /
|
||||||
|
cycle at level k−1), cycle level k costs 0.97 × price × (claim at level k−1 / base) × Δ(base
|
||||||
|
cycle / cycle), × 0.95^(k−1), minus the parts' value, two significant digits, never below
|
||||||
|
the level before. So with both tracks moving together a level adds output for about a
|
||||||
|
copy's price.
|
||||||
|
|
||||||
|
| drill | yield track | cycle track |
|
||||||
|
|---|---|---|
|
||||||
|
| PD-1 | Twin rotors 4 → 5, 6, 7, 8 | Flight computer 3 → 2.6, 2.2, 1.9, 1.7, 1.5 s |
|
||||||
|
| PX-4 | Wider chisel 20 → 24 … 40 | Pneumatic hammer 6 → 5.2, 4.5, 3.9, 3.4, 3 s |
|
||||||
|
| L100 | Wide-bore bit 110 → 130 … 220 | Spindle motor 8 → 7, 6, 5.2, 4.6, 4 s |
|
||||||
|
| L300 | Wider beam 1350 → 1600 … 2700 | Laser capacitors 10 → 8.7 … 5 s |
|
||||||
|
| LRE | Fine sieves 17k → 20k … 34k | Auger gearbox 12 → 10.4 … 6 s |
|
||||||
|
| EDE | Larger plates 36k → 43k … 72k | Charge coils 14 → 12.2 … 7 s |
|
||||||
|
| LCE | Core barrel 47k → 56k … 94k | Drill string 16 → 14 … 8 s |
|
||||||
|
|
||||||
|
Per ₥ against a copy (a drill alone on its grade): the first yield level ×1.02–1.05 (₥/min),
|
||||||
|
×1.04–1.09 (₥/click); whole yield track ×0.79–0.88; first cycle level ×0.94–1.01 (₥/click
|
||||||
|
×0.28–0.67: shorter cycles cost claims); both tracks to the top ×1.12–1.19. PD-1's cycle
|
||||||
|
levels add nothing in the sim: its player claims at most every 3 s (`gsClaimGap`).
|
||||||
|
|
||||||
|
| first moon of each grade | before (tiers, attachments) | after (tracks) |
|
||||||
|
|---|---|---|
|
||||||
|
| copies | C 13m15, B 22m26, A 1h10m37, S 2h59m52 | the same |
|
||||||
|
| boosts | C 13m15, B 22m26, A 1h08m39, S 3h00m21 (bought only copies and 2 advisors) | C 13m18, B 23m39, A 1h05m50, S 3h01m59 (233 copies, 234 levels, 2 advisors) |
|
||||||
|
|
||||||
|
At 0.85 (levels ~15% better than a copy) the boosts player reached A at 54 m and S at
|
||||||
|
2h26; at 1.0 and above it bought almost no levels.
|
||||||
|
|
||||||
# Balance notes: moon grades, clicker pacing (2026-10-21)
|
# Balance notes: moon grades, clicker pacing (2026-10-21)
|
||||||
|
|
||||||
Moon Miners is a clicker: the player is always clicking. `go run . sim` prints the grade
|
Moon Miners is a clicker: the player is always clicking. `go run . sim` prints the grade
|
||||||
|
|||||||
+1
-10
@@ -1,19 +1,10 @@
|
|||||||
# Production image: one Go binary with the templates, static files and SQL embedded.
|
# Production image: one Go binary with the templates, static files and SQL embedded.
|
||||||
FROM docker.io/library/golang:1.25-bookworm AS build
|
FROM docker.io/library/golang:1.25-bookworm AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
ARG TAILWIND_VERSION=v4.1.14
|
|
||||||
RUN curl -fsSL -o /usr/local/bin/tailwindcss \
|
|
||||||
https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 \
|
|
||||||
&& chmod +x /usr/local/bin/tailwindcss
|
|
||||||
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 ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
|
||||||
&& make app ESBUILD=/usr/local/bin/esbuild \
|
|
||||||
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
|
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=build /moonminers /moonminers
|
COPY --from=build /moonminers /moonminers
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
# Hand-off: Preact web client experiment (branch `s-preact`)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Find out whether a web client can feel as smooth and instant as the Defold client (which
|
|
||||||
predicts click results locally and eases corrections). It's built with Preact + JSX +
|
|
||||||
@preact/signals and compiled by esbuild, on the existing JSON API. It's served at `/app`
|
|
||||||
next to the classic htmx client at `/`. The user will compare them by feel: fast
|
|
||||||
claim/redrill/deploy clicks, and animations that never hitch.
|
|
||||||
|
|
||||||
## What's built
|
|
||||||
- `web/app/` (about 2,400 lines without the vendored code):
|
|
||||||
- `session.js`: a port of the Defold `session.lua`. Holds the `base`/`pending` signals,
|
|
||||||
the `shown`/`live` computeds, and an outbox that sends one request at a time with
|
|
||||||
`clientSeq`. A 422 rolls the prediction back. Navigation is predicted. It refetches at
|
|
||||||
`nextEnd` and keeps the URL in sync.
|
|
||||||
- `predict.js`: a port of `predict.lua` (claim, stake, redrill, derive). One deliberate
|
|
||||||
difference: readiness is local (`s.ready || s.end <= now`).
|
|
||||||
- `clock.js`: server clock offset. An `edge` signal is bumped by a timer at each drill
|
|
||||||
end. Also a single rAF ticker loop and eased numbers (`eased`, `hold`/`release`).
|
|
||||||
- `ui.jsx`:
|
|
||||||
- `Num`: eased number, written by the ticker.
|
|
||||||
- `Countdown`.
|
|
||||||
- `DrillBar`: a WAAPI `scaleX` animation keyed by tool id. A server correction changes
|
|
||||||
`playbackRate` so the bar lands on the true end without jumping.
|
|
||||||
- `SupplyBar`: clip-path.
|
|
||||||
- `buttons.jsx`: `ReadyButton`. It dispatches `mm:ready` when the clock (not a new
|
|
||||||
state) enables it, and the classic fx.js then plays the pop, sparks and sound.
|
|
||||||
- `header.jsx`, `moons.jsx`, `side.jsx`: the same markup and classes as the classic
|
|
||||||
templates, so they use `app.css`. `preact.css` covers only the differences. The whole
|
|
||||||
Moons slice: the moon list's Active/Past tabs, search and past pages, a past moon's
|
|
||||||
haul, the multi-well row and claim-wide (not predicted: the server splits it), the
|
|
||||||
moon sale, and the well panel's better-well line ("Deploy there" moves the view, so it
|
|
||||||
waits for the server).
|
|
||||||
- `dialog.jsx`: the classic confirm dialog (its markup, opened by the classic `ui.js`),
|
|
||||||
for the API's `confirm` texts (the moon sale).
|
|
||||||
- `fx.js`: effects at the click, from the predicted diff. Uses exports added to
|
|
||||||
`web/static/js/fx.js` (`floatText`, `stream`, `mark`, `centerOf`, `formatMoney`) and to
|
|
||||||
`sfx.js`.
|
|
||||||
- `flip.js`: FLIP for keyed rows, with positions relative to the container.
|
|
||||||
- `scene.js` + `scene-worker.js`: the classic `MoonScene` runs in a worker on an
|
|
||||||
OffscreenCanvas. It uses stand-ins for the canvas, its container, `ResizeObserver` and
|
|
||||||
`document`. Pointer and wheel events and the size are forwarded from the page. Drag and
|
|
||||||
zoom were verified. If the worker fails it falls back to the page's thread;
|
|
||||||
`?mainscene` forces that.
|
|
||||||
- `vendor/`: preact 11.0.1 and signals 2.11.3 / signals-core 1.14.4. `jsconfig.json`
|
|
||||||
holds the path aliases, including `three`, used by the worker bundle.
|
|
||||||
- Go: `GET /app` renders `web/templates/spa.html`. `MM_API_DELAY_MS` (dev only) delays
|
|
||||||
`/act/`, `/api/act/`, `/api/state` and htmx `GET /` for both clients.
|
|
||||||
- Build:
|
|
||||||
- `make app`: esbuild is fetched into `bin/` from the npm tarball. It outputs `app.js`,
|
|
||||||
`app.css`, `scene-worker.js` and `moon-worker.js` to `web/static/app/`, which is
|
|
||||||
gitignored and in `.dockerignore`.
|
|
||||||
- `make dev` watches the client.
|
|
||||||
- `make build` depends on `app`.
|
|
||||||
- The Dockerfile fetches esbuild and runs `make app`.
|
|
||||||
- `make check` (passes) also runs `scripts/check-signals-jsx.mjs`, which flags `.value`
|
|
||||||
inside JSX, and builds the client.
|
|
||||||
- AGENTS.md documents `web/app` and its rules. `/app` is in `templates_test.go` and
|
|
||||||
`test-endpoints.restclient`.
|
|
||||||
|
|
||||||
## How to try it
|
|
||||||
```
|
|
||||||
cd wt-s-preact && make app && go build -o bin/moonminers .
|
|
||||||
DB_DATABASE=mm_s_preact PORT=3151 MM_DEV=1 ./bin/moonminers serve
|
|
||||||
# second, slow server on the same DB: MM_API_DELAY_MS=300 PORT=3152 ...
|
|
||||||
```
|
|
||||||
Open `http://localhost:3151/app` and `http://localhost:3151/`. They share the same player
|
|
||||||
cookie. The database `mm_s_preact` is kept. `.env` was copied from `../server/.env` and is
|
|
||||||
gitignored. **The servers are stopped now**; restart them with the commands above.
|
|
||||||
|
|
||||||
## Measurements
|
|
||||||
|
|
||||||
### Second run (Oct 20, after the Moons slice was finished)
|
|
||||||
Headless Chrome 155 on macOS over the DevTools protocol (no puppeteer), Metal GPU
|
|
||||||
(`--use-angle=metal --enable-gpu --ignore-gpu-blocklist`), 1440×900. Same method as below:
|
|
||||||
the median of 3 alternating runs; latency from pointerdown to the first frame where the
|
|
||||||
side panel's drill line count changes. Each test starts from a known state set through the
|
|
||||||
API and admin endpoints (the wells topped up, one drone finished on the well). No other
|
|
||||||
engine was running; the machine's load average was 6 to 9 from other work.
|
|
||||||
|
|
||||||
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| claim to visible result | 30 ms | 13 ms | 347 ms | 14 ms |
|
|
||||||
| redrill to visible result | 31 ms | 15 ms | 331 ms | 14 ms |
|
|
||||||
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 6 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
|
|
||||||
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
|
|
||||||
| dropped frames (>25 ms) per run | 0, 0, 0 | 0, 0, 0 | 0, 0, 1 | 0, 0, 0 |
|
|
||||||
|
|
||||||
Main-thread time for one claim (Chrome's Performance.getMetrics counters, from the click to
|
|
||||||
1.5 s after, 0 ms delay, median of 3):
|
|
||||||
|
|
||||||
| | script | layout | style | all tasks |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| classic | 25 ms | 5 ms | 9 ms | 69 ms |
|
|
||||||
| app | 11 ms | 8 ms | 10 ms | 62 ms |
|
|
||||||
|
|
||||||
- The classic client's first claim of a page load had 109 ms of script (the AudioContext on
|
|
||||||
the first gesture, as below); the others 24–25 ms.
|
|
||||||
- Most of "all tasks" is the viewport and other frames in the window, the same in both.
|
|
||||||
- With an idle GPU the frame drops are gone for both clients: the earlier noise was the
|
|
||||||
other engine.
|
|
||||||
- Side-by-side screenshots (moon, well panel, multi-well moon, past moons) are identical but
|
|
||||||
for the header's Classic/Admin link.
|
|
||||||
|
|
||||||
### First run
|
|
||||||
Headless Chromium 156 (Playwright's cache) with puppeteer-core, GPU via
|
|
||||||
`--enable-gpu --use-angle=gl-egl`. Swiftshader is far too slow to use. Each figure is the
|
|
||||||
median of 3 alternating runs. The latency is the time from pointerdown to the first frame
|
|
||||||
where the side panel's drill lines change.
|
|
||||||
|
|
||||||
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| claim to visible result | 52 ms | 14 ms | 342 ms | 12 ms |
|
|
||||||
| redrill to visible result | 41 ms | 11 ms | 329 ms | 8 ms |
|
|
||||||
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 5 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
|
|
||||||
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
|
|
||||||
| dropped frames (>25 ms) per run | 9, 7, 2 | 7, 12, 1 | 9, 5, 1 | 4, 4, 2 |
|
|
||||||
|
|
||||||
- The classic client drops clicks while a request runs: `body.is-busy` sets
|
|
||||||
`pointer-events: none`.
|
|
||||||
- Frame drops were noisy and about the same for both clients. Another agent's Defold engine
|
|
||||||
was using about 460% CPU and the GPU.
|
|
||||||
- Long-animation-frame entries show the frames waiting on the GPU or presentation rather
|
|
||||||
than on scripts.
|
|
||||||
- The worker viewport and the main-thread viewport (`app-main`) showed no clear difference
|
|
||||||
under this load.
|
|
||||||
- The first click in each run has a long task of about 120 ms in both clients. It comes
|
|
||||||
from `sfx.js` creating the AudioContext on the first gesture.
|
|
||||||
- The harness lives in the session scratchpad, which is not kept. If it's needed again,
|
|
||||||
recreate it from this description: `measure.mjs` (in-page rAF sampling, longtask/LoAF
|
|
||||||
observers, a `watch` predicate on the drill-line count, `page.mouse.click`), `setup.mjs`
|
|
||||||
(a player via the admin endpoints: unlock all, 5 drones, credits, a moon) and
|
|
||||||
`runall.sh`.
|
|
||||||
|
|
||||||
## Unfinished / next steps
|
|
||||||
Done since the first hand-off: the well panel and Moons section gaps (better well, multi-well
|
|
||||||
rows and claim-wide, moon sale, past moons tab, search and pages), navigation and sale and
|
|
||||||
mined-out effects, the measurements on a quiet GPU with main-thread time per claim, and the
|
|
||||||
side-by-side screenshots. What's left:
|
|
||||||
1. Other sections (Equipment, Store, Market, Refinery, Advisors, Leaderboard, Help), buying
|
|
||||||
a moon (the new-moon teaser and reveal) and the store link still open the classic client.
|
|
||||||
The API has every section's state and the prediction rules for sells and refinery
|
|
||||||
batches (api.md "Prediction").
|
|
||||||
2. Moon sale, claim-wide and "Deploy there" wait for the server (a spinner); predicting them
|
|
||||||
would need the server's split of multi-well claims and the sale's payout rules.
|
|
||||||
3. The measurement harness lived in a session scratchpad again (CDP: an in-page rAF watch on
|
|
||||||
the drill-line count, clicks through Input.dispatchMouseEvent, Performance.getMetrics
|
|
||||||
around a claim). Worth keeping in `scripts/` if the comparison continues.
|
|
||||||
4. Report: rough line counts are above. Hard parts:
|
|
||||||
- predicted stake ids differ from the server's, so drill rows are keyed by tool id;
|
|
||||||
- easing bar corrections (playbackRate);
|
|
||||||
- OffscreenCanvas shims for OrbitControls (`getRootNode`, pointer capture, readonly
|
|
||||||
`Event.type`);
|
|
||||||
- avoiding ticker writes conflicting with Preact's children (the ticker writes into
|
|
||||||
empty spans).
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
- `live` re-derives counts on every `edge`. Components read `live.value` in their body,
|
|
||||||
which is allowed. Never read `.value` inside JSX.
|
|
||||||
- Readiness from the clock: `clockCaused()` compares the time of the last edge with the
|
|
||||||
last `shown` change, because Preact re-renders asynchronously.
|
|
||||||
- The worker bundle includes three.js (about 600 KB). The page itself uses the import map
|
|
||||||
only for the main-thread fallback.
|
|
||||||
- esbuild needs `--tsconfig=web/app/jsconfig.json` so that `three` resolves for
|
|
||||||
`web/static/js/scene.js` too.
|
|
||||||
- Never `pkill -f` a pattern contained in your own command line; it kills your own shell.
|
|
||||||
- `scripts/check-signals-jsx.mjs` flags any `.value` in JSX, including plain objects' fields
|
|
||||||
(the sale lines' `value`): destructure them under another name.
|
|
||||||
- Headless Chrome plays the game's sounds through the speakers; start it with `--mute-audio`.
|
|
||||||
+6
-37
@@ -1,30 +1,11 @@
|
|||||||
TAILWIND_VERSION := v4.1.14
|
.PHONY: build dev migrate load-config check
|
||||||
TAILWIND := bin/tailwindcss
|
|
||||||
CSS := web/static/css/app.css
|
|
||||||
ESBUILD_VERSION := 0.28.2
|
|
||||||
ESBUILD := bin/esbuild
|
|
||||||
# The Preact client (web/app) bundled into web/static/app: app.js, app.css and the viewport's
|
|
||||||
# worker with three.js (scene-worker.js, which starts moon-worker.js for meshes). The page loads the classic client's modules
|
|
||||||
# (/static/js/...) by URL.
|
|
||||||
APP := $(ESBUILD) app=web/app/main.jsx scene-worker=web/app/scene-worker.js moon-worker=web/static/js/moon-worker.js --bundle --format=esm --target=es2022 '--external:/static/*' --tsconfig=web/app/jsconfig.json --outdir=web/static/app
|
|
||||||
|
|
||||||
.PHONY: build dev css app migrate load-config check
|
build:
|
||||||
|
|
||||||
build: css app
|
|
||||||
go build -o bin/moonminers .
|
go build -o bin/moonminers .
|
||||||
|
|
||||||
# Server on :3000 that reloads templates and static files from disk; CSS and the Preact
|
# Server on :3000 that reloads templates and static files from disk.
|
||||||
# client rebuild on change.
|
dev:
|
||||||
dev: $(TAILWIND) $(ESBUILD)
|
MM_DEV=1 go run . serve
|
||||||
$(TAILWIND) -i web/css/app.css -o $(CSS) --watch=always & \
|
|
||||||
$(APP) --sourcemap --watch=forever & \
|
|
||||||
trap 'kill $$(jobs -p)' EXIT; MM_DEV=1 go run . serve
|
|
||||||
|
|
||||||
css: $(TAILWIND)
|
|
||||||
$(TAILWIND) -i web/css/app.css -o $(CSS) --minify
|
|
||||||
|
|
||||||
app: $(ESBUILD)
|
|
||||||
$(APP) --minify
|
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
go run . migrate
|
go run . migrate
|
||||||
@@ -34,17 +15,5 @@ load-config:
|
|||||||
|
|
||||||
check:
|
check:
|
||||||
test -z "$$(gofmt -l .)" && go vet ./...
|
test -z "$$(gofmt -l .)" && go vet ./...
|
||||||
go test -run '^(TestTemplates|TestAPI|TestSharedQuotes)$$' -count=1 .
|
go test -run '^(TestTemplates|TestAPI|TestSharedQuotes|TestAdminGate)$$' -count=1 .
|
||||||
for f in web/static/js/*.js; do node --check $$f || exit 1; done
|
for f in web/static/js/*.js; do node --check $$f || exit 1; done
|
||||||
node --no-warnings scripts/check-moon-traits.mjs
|
|
||||||
node scripts/check-signals-jsx.mjs web/app
|
|
||||||
$(APP) --minify --log-level=warning
|
|
||||||
|
|
||||||
$(ESBUILD):
|
|
||||||
mkdir -p bin
|
|
||||||
curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-$(ESBUILD_VERSION).tgz | tar xz -C bin --strip-components=2 package/bin/esbuild
|
|
||||||
|
|
||||||
$(TAILWIND):
|
|
||||||
mkdir -p bin
|
|
||||||
curl -fsSL -o $@ https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
|
|
||||||
chmod +x $@
|
|
||||||
+68
-72
@@ -1,19 +1,17 @@
|
|||||||
# Moon Miners server
|
# Moon Miners server
|
||||||
|
|
||||||
A browser mining game. Players own moons (staking sources) with resource wells, buy and
|
The server of a 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
|
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 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
|
resources into refined items on a timer; those sell for more and are needed as parts for
|
||||||
higher tool upgrades.
|
the higher levels of the drills' upgrade tracks.
|
||||||
|
|
||||||
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
|
It's a Go server: game rules run in Go, one Postgres transaction per action, behind a JSON
|
||||||
a three.js view of the selected moon, generated procedurally from its id. Game rules run in
|
API (`api.md`) that the Defold client (`../client`) plays on. The admin pages (`/utils`) are
|
||||||
Go, one Postgres transaction per action. The page doesn't reload on timers: countdowns tick
|
server-rendered HTML with htmx. `GET /` redirects to `/utils` for now.
|
||||||
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.
|
|
||||||
|
|
||||||
Players join anonymously: on the first visit the server creates a player and stores its id in
|
Players join anonymously: on the first request the server creates a player and sends its id
|
||||||
a cookie. No wallet or sign-up needed.
|
back (`X-MM-User`, also a cookie). No wallet or sign-up needed.
|
||||||
|
|
||||||
This is `server/` in the repo (the Defold client is `../client`). Run the commands below
|
This is `server/` in the repo (the Defold client is `../client`). Run the commands below
|
||||||
from this directory.
|
from this directory.
|
||||||
@@ -24,8 +22,6 @@ from this directory.
|
|||||||
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
|
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
|
||||||
- Node.js, only for `make check` (syntax-checks the JS)
|
- Node.js, only for `make check` (syntax-checks the JS)
|
||||||
|
|
||||||
`make` downloads the Tailwind standalone CLI and esbuild into `bin/` on first use.
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
The server reads `.env` from its working directory (`server/`), or if there is none, from the
|
The server reads `.env` from its working directory (`server/`), or if there is none, from the
|
||||||
@@ -42,11 +38,15 @@ ADMIN_DB_USER=postgres # superuser, only used by `migrate`
|
|||||||
ADMIN_DB_PASSWORD=postgres
|
ADMIN_DB_PASSWORD=postgres
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates
|
Optional: `PORT` (default 3000), `HOST` (the address to listen on, e.g. `127.0.0.1`; unset:
|
||||||
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated
|
every interface), `DB_SSLMODE` (default `disable`), `ADMIN_PASSWORD` (the
|
||||||
player ids allowed on the admin pages; unset means no admins), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit new
|
password of the admin pages, `/utils`; unset: they're locked), `ADMIN_USER_IDS`
|
||||||
players by the last `X-Forwarded-For` address instead of the connection's; new players are
|
(comma-separated player ids that are admins in the game API: `state.admin`, `admin-grant`,
|
||||||
limited to 10 a minute per IP).
|
saving moon settings), `MM_DEV=1` (reload templates and static files from disk; no admin
|
||||||
|
login, and every player is an admin), `MM_WEB_DIR` (the browser build served at `/`; default `../client/dist/web/MoonMiners`,
|
||||||
|
none there: `/` redirects to `/utils`), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit by
|
||||||
|
the last `X-Forwarded-For` address instead of the connection's, and mark the admin cookie
|
||||||
|
Secure on HTTPS; new players are limited to 10 a minute per IP).
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
@@ -59,7 +59,9 @@ limited to 10 a minute per IP).
|
|||||||
make migrate
|
make migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Run the server and open http://localhost:3000:
|
3. Run the server (`MM_DEV=1`: no admin login). The admin is at
|
||||||
|
http://localhost:3000/utils; the game at http://localhost:3000 once the client's
|
||||||
|
`make web` has built it (then restart the server), or run the desktop client against it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dev
|
make dev
|
||||||
@@ -69,14 +71,15 @@ limited to 10 a minute per IP).
|
|||||||
pulling changes (or editing `config/game.json`), run `make migrate` again: new
|
pulling changes (or editing `config/game.json`), run `make migrate` again: new
|
||||||
migrations and the store items' numbers only reach the database through it.
|
migrations and the store items' numbers only reach the database through it.
|
||||||
|
|
||||||
Other commands: `make build` (CSS, the Preact bundle and `bin/moonminers`), `make check` (gofmt, vet, the template
|
Other commands: `make build` (`bin/moonminers`), `make check` (gofmt, vet, the template
|
||||||
check in `templates_test.go`, which renders every page against sample game states without a
|
check in `templates_test.go`, which renders every admin page without a database, the API and
|
||||||
database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and
|
admin login tests, JS syntax), `make load-config` (sync the store items in the database with
|
||||||
`terrain.js` agree on moon looks), `make load-config` (sync the store items in the database with `config/game.json`).
|
`config/game.json`).
|
||||||
|
|
||||||
The binary's commands: `serve` (default), `migrate`, `load-config`, `sim [file]` (print the
|
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),
|
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).
|
and `moon-traits` (JSON on stdin; prints the moon looks `moondesc.go` derives, to compare
|
||||||
|
with the client's `terrain.lua`).
|
||||||
Reset the local database (compose commands at the repo root): `podman-compose down -v && podman-compose up -d db`, then `make migrate` here.
|
Reset the local database (compose commands at the repo root): `podman-compose down -v && podman-compose up -d db`, then `make migrate` here.
|
||||||
|
|
||||||
## Database changes
|
## Database changes
|
||||||
@@ -93,17 +96,22 @@ same schema (compare `pg_dump --schema-only`). The migrations so far: 001 drops
|
|||||||
tables, 002 moon settings, 003 config overrides, 004 live ops, 005 refinery, 006 player
|
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,
|
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
|
non-negative well supply), 009 schema cleanup (old columns, moon images), 010 drill
|
||||||
attachments and advisors, 011 market pressure, 012 onboarding, 013 moon type
|
attachments (dropped in 020) and advisors, 011 market pressure, 012 onboarding, 013 moon type
|
||||||
(`staking_source.kind`, now that moons get generated names), 014 drill reach, 015 moon
|
(`staking_source.kind`, now that moons get generated names), 014 drill reach, 015 moon
|
||||||
rating, 016 advisor seats, 017 save codes, 018 moon grade, 019 refinery batch size
|
rating, 016 advisor seats, 017 save codes, 018 moon grade, 019 refinery batch size
|
||||||
(`users.refinery_batch_level`).
|
(`users.refinery_batch_level`), 020 drill upgrade tracks (`inventory_item.yield_level`,
|
||||||
|
`cycle_level`; old tiers became cycle levels, attachments were refunded and dropped,
|
||||||
|
`upgrade_item` dropped, stake cycles in tenths of a second), 022 indexes for the per-request
|
||||||
|
state reads (moons, wells, tools and stakes by player, stakes by well and by tool).
|
||||||
|
|
||||||
## Game config
|
## Game config
|
||||||
|
|
||||||
`config/game.json` holds the balance numbers (starting credits, resources and tools, moon
|
`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
|
price, well supply range, `wellCountWeights`: how many wells a new moon gets, resource prices
|
||||||
in credits per unit), the store items with their
|
in credits per unit), the store items with their
|
||||||
upgrade tiers (tiers can also need refined `parts`; a drill cycle is `completionTimeInSecs`),
|
two upgrade tracks (`tracks.yield` and `tracks.cycle`: a name and levels, each setting the
|
||||||
|
claim per cycle or the cycle in seconds flat, with a `price` and, higher up, refined `parts`;
|
||||||
|
a drill's base cycle is `completionTimeInSecs`, its reach never changes),
|
||||||
the moon types (a name, a placeholder description: the look is procedural and the
|
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`:
|
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 of the resources its wells hold, 1 if unlisted), moon names (`moonNames`: roots, and
|
||||||
@@ -117,11 +125,9 @@ function of name and time (`market.go`), which also gives the 24h charts; sells
|
|||||||
A player's own selling lowers their price a little (`marketPressure`): each sale adds its
|
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
|
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`.
|
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`
|
Each drill's tracks are upgraded per tool, one level at a time, on its Equipment card or
|
||||||
and `speed`/`yield` multipliers) are bought in the Store and equipped on a drill from its
|
in its panel, where idle drills can also be sold back for `equipmentResale.factor` of their
|
||||||
panel in Equipment, where idle drills and spare attachments can also be sold back for
|
credit price (its price plus its track levels; refined parts aren't refunded). A moon sells back (`moonResale`) for what's left in its wells at market
|
||||||
`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
|
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
|
`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
|
advisor a hire `price` and `speed`/`yield`/`sell`/`refine` multipliers) boost the whole
|
||||||
@@ -136,18 +142,21 @@ Admins can change these numbers without a deploy on `/utils/economy` (see Admin
|
|||||||
|
|
||||||
## Admin panels
|
## Admin panels
|
||||||
|
|
||||||
Admins are the player ids listed in `ADMIN_USER_IDS` (comma-separated). With `MM_DEV=1`
|
Server-rendered pages (Go templates, htmx, `web/static/css/admin.css`). Sign in at
|
||||||
everyone is an admin, so never set it in production. Other players get a 403 (the admin pages
|
`/utils/login` with `ADMIN_PASSWORD`: the session is a random token in the `mm_admin` cookie
|
||||||
show "missing permission"). Every admin page
|
(HttpOnly, SameSite=Lax, 7 days), kept in memory, so a restart signs admins out. A wrong
|
||||||
shares one nav: Players · Moons · Economy · Live ops · Game.
|
password waits a second; tries are limited to 10 a minute per IP. Without a session, pages
|
||||||
|
redirect to the login and actions get a 401. With `MM_DEV=1` there is no login, so never set
|
||||||
|
it in production; with no `ADMIN_PASSWORD` (and no `MM_DEV`) the admin is locked. Every admin
|
||||||
|
page shares one nav: Players · Economy · Live ops · Log out.
|
||||||
|
|
||||||
| Page | What it's for |
|
| Page | What it's for |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/utils` | Redirects to `/utils/players`. |
|
| `/utils` | Redirects to `/utils/players` (`/` redirects here). |
|
||||||
|
| `/utils/login` | The login form; `POST /utils/logout` signs out. |
|
||||||
| `/utils/players` | Find players (id prefix or name), sort by last seen, activity, newest, richest. |
|
| `/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/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 its yield and cycle tracks), resources, refined items, market, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
|
||||||
| `/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. |
|
| `/utils/liveops` | Dashboard, timed events and announcements. |
|
||||||
|
|
||||||
Actions (all `POST`, confirmed in the page's own dialog):
|
Actions (all `POST`, confirmed in the page's own dialog):
|
||||||
@@ -158,26 +167,26 @@ Actions (all `POST`, confirmed in the page's own dialog):
|
|||||||
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
|
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
|
||||||
| `/utils/liveops/{what}` | `events/save`, `announcements/save` |
|
| `/utils/liveops/{what}` | `events/save`, `announcements/save` |
|
||||||
| `/utils/liveops/{what}/{id}/{action}` | toggle or delete an event, delete an announcement |
|
| `/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` saves it: the admin cookie, an `X-MM-User` in `ADMIN_USER_IDS`, or `MM_DEV=1` |
|
||||||
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` (admins) saves it |
|
|
||||||
|
|
||||||
Request examples are in `test-endpoints.restclient`.
|
Request examples are in `test-endpoints.restclient`.
|
||||||
|
|
||||||
### Players
|
### Players
|
||||||
|
|
||||||
Every admin action is logged in `event_log` with the admin's id (`admin_*` kinds, which the
|
Every admin action is logged in `event_log` (`admin_*` kinds, which the live-ops stats
|
||||||
live-ops stats ignore). Banned players see "This account is suspended" and their actions get a
|
ignore) with `"admin": "admin"` (the admin-grant API action logs the admin player's id).
|
||||||
403.
|
Banned players get a 403 from the API ("This account is suspended").
|
||||||
|
|
||||||
### Moons
|
### Moons
|
||||||
|
|
||||||
Each moon's look is generated from its id (terrain technique, noise, craters, colors). The lab
|
Each moon's look is generated from its id (terrain technique, noise, craters, colors) by the
|
||||||
edits the shared settings that every moon starts from; they're stored as one JSON row in
|
Defold client. The shared settings every moon starts from are stored as one JSON row in
|
||||||
`moon_settings` and merged over the defaults in `web/static/js/terrain.js`.
|
`moon_settings` (`/api/moon-settings`) and merged over the defaults in the client's
|
||||||
|
`modules/moon/terrain.lua`.
|
||||||
|
|
||||||
### Economy
|
### Economy
|
||||||
|
|
||||||
A form for the constants, resource prices, store items and upgrade tiers (including parts),
|
A form for the constants, resource prices, store items and their upgrade tracks (including parts),
|
||||||
and the refinery, plus a raw JSON editor for every field. The analysis updates as you type:
|
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
|
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
|
greedy-player sim. Each save stores the values that differ from `config/game.json` as a new row
|
||||||
@@ -191,31 +200,19 @@ and store items are re-synced.
|
|||||||
- Timed events: yield, sell price or mining speed multipliers, applied server-side.
|
- Timed events: yield, sell price or mining speed multipliers, applied server-side.
|
||||||
- Announcements.
|
- Announcements.
|
||||||
|
|
||||||
Live events and announcements show in a banner at the top of the game. Times are UTC.
|
Live events and announcements reach the game in the API's `banner`. Times are UTC.
|
||||||
|
|
||||||
## Interface
|
The admin pages' JS (`web/static/js/`): `tabs.js` (sliding tab highlight), `enter.js`
|
||||||
|
(entrance animations), `ui.js` (confirm dialog), `liveops.js`, `economy.js` (the economy
|
||||||
Server-rendered regions swapped by htmx, which morphs them into the page with the idiomorph
|
editor).
|
||||||
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
|
## Security
|
||||||
|
|
||||||
- Players are identified only by the `mm_user` cookie (HttpOnly, SameSite=Lax). New players
|
- Players are identified only by their id (`X-MM-User`, or the `mm_user` cookie: HttpOnly,
|
||||||
are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind a proxy).
|
SameSite=Lax). New players are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind
|
||||||
|
a proxy).
|
||||||
|
- The admin pages need the admin login (`ADMIN_PASSWORD`, see Admin panels); without a
|
||||||
|
password they're locked.
|
||||||
- Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
|
- Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
|
||||||
so oversized ones get a 413.
|
so oversized ones get a 413.
|
||||||
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
|
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
|
||||||
@@ -237,16 +234,15 @@ First deploy:
|
|||||||
```dotenv
|
```dotenv
|
||||||
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
|
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
|
||||||
DB_PASSWORD=... # strong; app user "moonminers"
|
DB_PASSWORD=... # strong; app user "moonminers"
|
||||||
ADMIN_USER_IDS= # fill in at step 5
|
ADMIN_PASSWORD=... # strong; the admin pages at /utils
|
||||||
|
ADMIN_USER_IDS= # optional: player ids that are admins in the game API
|
||||||
```
|
```
|
||||||
|
|
||||||
Never set `MM_DEV` in production: it makes every player an admin.
|
Never set `MM_DEV` in production: it turns off the admin login and makes every player an admin.
|
||||||
3. **Domains**: your domain → service `app`, port `3000`, HTTPS on.
|
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
|
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.
|
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
|
5. Deploy. The admin pages are at `/utils` (sign in with `ADMIN_PASSWORD`).
|
||||||
(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.
|
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.
|
Back up `db-data` with `pg_dump` or Dokploy's volume backups.
|
||||||
|
|||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Admin login for /utils/*: one shared password (ADMIN_PASSWORD), a login form at
|
||||||
|
// /utils/login and a random session token in the mm_admin cookie (kept in memory, so a
|
||||||
|
// restart signs admins out). With MM_DEV=1 there is no login. Without a password (and
|
||||||
|
// without MM_DEV) the admin is locked.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
adminCookie = "mm_admin"
|
||||||
|
adminSessionTTL = 7 * 24 * time.Hour
|
||||||
|
// adminLabel stands for the admin in event_log ("admin" in admin_* events).
|
||||||
|
adminLabel = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loginFailDelay slows down password guessing (tests set it to 0).
|
||||||
|
var loginFailDelay = time.Second
|
||||||
|
|
||||||
|
// adminSessions are the signed-in admins' tokens and when they expire.
|
||||||
|
type adminSessions struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
tokens map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adminSessions) add() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
token := hex.EncodeToString(b)
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.tokens == nil {
|
||||||
|
a.tokens = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for t, exp := range a.tokens {
|
||||||
|
if now.After(exp) {
|
||||||
|
delete(a.tokens, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.tokens[token] = now.Add(adminSessionTTL)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adminSessions) valid(token string) bool {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
exp, ok := a.tokens[token]
|
||||||
|
return ok && time.Now().Before(exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adminSessions) remove(token string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
delete(a.tokens, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminLocked: no password set and not in dev, so nobody can sign in.
|
||||||
|
func (s *server) adminLocked() bool { return !s.dev && s.adminPassword == "" }
|
||||||
|
|
||||||
|
// adminSession reports whether the request carries a signed-in admin's cookie.
|
||||||
|
func (s *server) adminSession(r *http.Request) bool {
|
||||||
|
if s.adminLocked() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(adminCookie)
|
||||||
|
return err == nil && s.admins.valid(c.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireAdmin lets signed-in admins (or anyone with MM_DEV=1) through. Others get the
|
||||||
|
// login page: a redirect for page loads, 401 with HX-Redirect for htmx and other requests.
|
||||||
|
func (s *server) requireAdmin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.dev || s.adminSession(r) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
login := "/utils/login"
|
||||||
|
if r.Method == http.MethodGet && r.Header.Get("HX-Request") != "true" {
|
||||||
|
http.Redirect(w, r, login+"?next="+r.URL.EscapedPath(), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("HX-Redirect", login)
|
||||||
|
http.Error(w, "Sign in to the admin", http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginPage is the login form's data.
|
||||||
|
type LoginPage struct {
|
||||||
|
Locked bool
|
||||||
|
Error string
|
||||||
|
Next string
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeNext is where to go after signing in: a /utils page, else the admin hub.
|
||||||
|
func safeNext(next string) string {
|
||||||
|
if next == "/utils" || strings.HasPrefix(next, "/utils/") && !strings.HasPrefix(next, "/utils/login") {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return "/utils"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.dev || s.adminSession(r) {
|
||||||
|
http.Redirect(w, r, safeNext(r.FormValue("next")), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, "login", &LoginPage{Locked: s.adminLocked(), Next: safeNext(r.FormValue("next"))})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
next := safeNext(r.PostFormValue("next"))
|
||||||
|
if s.adminLocked() {
|
||||||
|
s.renderLogin(w, http.StatusForbidden, &LoginPage{Locked: true, Next: next})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.logins != nil && !s.logins.allow(r) {
|
||||||
|
s.renderLogin(w, http.StatusTooManyRequests, &LoginPage{Error: "Too many tries, wait a minute.", Next: next})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Hashes, so the comparison doesn't depend on the lengths either.
|
||||||
|
got := sha256.Sum256([]byte(r.PostFormValue("password")))
|
||||||
|
want := sha256.Sum256([]byte(s.adminPassword))
|
||||||
|
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
||||||
|
time.Sleep(loginFailDelay)
|
||||||
|
s.renderLogin(w, http.StatusUnauthorized, &LoginPage{Error: "Wrong password.", Next: next})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: adminCookie,
|
||||||
|
Value: s.admins.add(),
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(adminSessionTTL / time.Second),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secureRequest(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) renderLogin(w http.ResponseWriter, status int, page *LoginPage) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
s.render(w, "login", page)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie(adminCookie); err == nil {
|
||||||
|
s.admins.remove(c.Value)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: adminCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
|
||||||
|
Secure: secureRequest(r), SameSite: http.SameSiteLaxMode})
|
||||||
|
http.Redirect(w, r, "/utils/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// secureRequest: the request came over HTTPS (directly, or through the proxy with
|
||||||
|
// TRUST_PROXY=1), so the cookie can be Secure.
|
||||||
|
func secureRequest(r *http.Request) bool {
|
||||||
|
return r.TLS != nil || os.Getenv("TRUST_PROXY") == "1" && r.Header.Get("X-Forwarded-Proto") == "https"
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The admin login gate: locked without a password, open in dev, and a redirect to the
|
||||||
|
// login without a session.
|
||||||
|
func TestAdminGate(t *testing.T) {
|
||||||
|
loginFailDelay = 0
|
||||||
|
tmpl, err := (&server{}).parseTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) })
|
||||||
|
get := func(s *server, path string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest("GET", path, nil)
|
||||||
|
for _, c := range cookies {
|
||||||
|
r.AddCookie(c)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.requireAdmin(ok).ServeHTTP(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
login := func(s *server, password string) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest("POST", "/utils/login", strings.NewReader(url.Values{"password": {password}, "next": {"/utils/economy"}}.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleLogin(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dev: no login.
|
||||||
|
if w := get(&server{dev: true}, "/utils/players"); w.Code != http.StatusTeapot {
|
||||||
|
t.Errorf("dev: %d, want through", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No password: locked, even with an empty password.
|
||||||
|
locked := &server{templates: tmpl}
|
||||||
|
if w := get(locked, "/utils/players"); w.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("locked: %d, want a redirect to the login", w.Code)
|
||||||
|
}
|
||||||
|
if w := login(locked, ""); w.Code != http.StatusForbidden || len(w.Result().Cookies()) > 0 {
|
||||||
|
t.Errorf("locked login: %d %v, want 403 and no cookie", w.Code, w.Result().Cookies())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A password: no session redirects to the login, htmx and actions get 401.
|
||||||
|
s := &server{templates: tmpl, adminPassword: "secret"}
|
||||||
|
w := get(s, "/utils/players?q=x")
|
||||||
|
if w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "/utils/login?next=/utils/players") {
|
||||||
|
t.Errorf("no session: %d %q, want a redirect to the login", w.Code, w.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("POST", "/utils/economy/save", nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
s.requireAdmin(ok).ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusUnauthorized || w.Header().Get("HX-Redirect") != "/utils/login" {
|
||||||
|
t.Errorf("no session, POST: %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
if w := get(s, "/utils/players", &http.Cookie{Name: adminCookie, Value: "made-up"}); w.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("unknown session: %d, want a redirect", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong password: no cookie. Right one: a session that gets through.
|
||||||
|
if w := login(s, "wrong"); w.Code != http.StatusUnauthorized || len(w.Result().Cookies()) > 0 {
|
||||||
|
t.Errorf("wrong password: %d, want 401 and no cookie", w.Code)
|
||||||
|
}
|
||||||
|
w = login(s, "secret")
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/utils/economy" || len(cookies) != 1 {
|
||||||
|
t.Fatalf("login: %d %q %v", w.Code, w.Header().Get("Location"), cookies)
|
||||||
|
}
|
||||||
|
if c := cookies[0]; c.Name != adminCookie || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode {
|
||||||
|
t.Errorf("cookie %+v, want HttpOnly, SameSite Lax", c)
|
||||||
|
}
|
||||||
|
if w := get(s, "/utils/players", cookies[0]); w.Code != http.StatusTeapot {
|
||||||
|
t.Errorf("with session: %d, want through", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only /utils pages after the login.
|
||||||
|
for next, want := range map[string]string{"/utils/players": "/utils/players", "https://evil": "/utils",
|
||||||
|
"//evil": "/utils", "/api/state": "/utils", "/utils": "/utils", "/utils/login": "/utils"} {
|
||||||
|
if got := safeNext(next); got != want {
|
||||||
|
t.Errorf("safeNext(%q) = %q, want %q", next, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// Advisors: people a player hires once, for credits, who boost the whole operation (drill
|
// Advisors: people a player hires once, for credits, who boost the whole operation (drill
|
||||||
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments).
|
// speed, yield, sell prices, refinery speed; bonus.go combines them).
|
||||||
// Seats are numbered: players start with one and buy more (advisors.seatPrices), like
|
// Seats are numbered: players start with one and buy more (advisors.seatPrices), like
|
||||||
// refinery slots. Hiring needs a free seat and seats the advisor in the lowest one. Only
|
// refinery slots. Hiring needs a free seat and seats the advisor in the lowest one. Only
|
||||||
// seated advisors give their bonus. A hired advisor can be unseated (kept on the payroll,
|
// seated advisors give their bonus. A hired advisor can be unseated (kept on the payroll,
|
||||||
// no bonus), seated again in a free seat, swapped with another (seated or not) or
|
// no bonus), seated again in a free seat, swapped with another (seated or not) or
|
||||||
// dismissed. Dismissing refunds nothing (the price is a hiring fee, like seats and slots,
|
// dismissed. Dismissing refunds nothing (the price is a hiring fee, like seats and slots,
|
||||||
// which aren't refunded either); hiring them again costs the full price. Like attachments,
|
// which aren't refunded either); hiring them again costs the full price. Like every bonus,
|
||||||
// changes count from the next action: running drills keep their bonus.
|
// changes count from the next action: running drills keep their bonus.
|
||||||
// Tables advisor_hire (seat null: not seated) and users.advisor_seats.
|
// Tables advisor_hire (seat null: not seated) and users.advisor_seats.
|
||||||
|
|
||||||
|
|||||||
+54
-47
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// JSON API for game clients other than the htmx page (a Defold client): the same state the
|
// JSON API for the game client (Defold): the state the views build (views.go) and the actions, run through runAction (server.go). Times are unix
|
||||||
// views build and the same actions, run through runAction (server.go). Times are unix
|
|
||||||
// milliseconds (null when unset), durations seconds; the client formats. See api.md.
|
// milliseconds (null when unset), durations seconds; the client formats. See api.md.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -132,18 +131,17 @@ type APIMoonListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type APITool struct {
|
type APITool struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
StoreItemID int `json:"storeItemId"`
|
StoreItemID int `json:"storeItemId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Code string `json:"code"` // model code, e.g. "PD-1"
|
Code string `json:"code"` // model code, e.g. "PD-1"
|
||||||
Image string `json:"image"` // static URL
|
Image string `json:"image"` // static URL
|
||||||
Tier int `json:"tier"`
|
YieldLevel int `json:"yieldLevel"` // levels on its upgrade tracks (0: base)
|
||||||
MaxTier int `json:"maxTier"`
|
CycleLevel int `json:"cycleLevel"`
|
||||||
Reach int `json:"reach"` // wells one cycle mines
|
Reach int `json:"reach"` // wells one cycle mines
|
||||||
CycleSecs int `json:"cycleSecs"` // a stake now, with bonuses and live events
|
CycleSecs float64 `json:"cycleSecs"` // a stake now, with bonuses and live events (tenths)
|
||||||
Yield float64 `json:"yield"`
|
Yield float64 `json:"yield"`
|
||||||
Attachments []string `json:"attachments"`
|
StakeID string `json:"stakeId,omitempty"` // mining, if set
|
||||||
StakeID string `json:"stakeId,omitempty"` // mining, if set
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIStake struct {
|
type APIStake struct {
|
||||||
@@ -156,7 +154,7 @@ type APIStake struct {
|
|||||||
Reach int `json:"reach"`
|
Reach int `json:"reach"`
|
||||||
Start int64 `json:"start"`
|
Start int64 `json:"start"`
|
||||||
End int64 `json:"end"`
|
End int64 `json:"end"`
|
||||||
Secs int `json:"durationSecs"`
|
Secs float64 `json:"durationSecs"`
|
||||||
Ready bool `json:"ready"`
|
Ready bool `json:"ready"`
|
||||||
Take float64 `json:"take"` // what claiming it would get now
|
Take float64 `json:"take"` // what claiming it would get now
|
||||||
// The web's words for its reach: "Mines 3 wells", "Strips the whole moon", "" for one.
|
// The web's words for its reach: "Mines 3 wells", "Strips the whole moon", "" for one.
|
||||||
@@ -167,7 +165,7 @@ type APIStake struct {
|
|||||||
Gets *float64 `json:"gets,omitempty"`
|
Gets *float64 `json:"gets,omitempty"`
|
||||||
Here *float64 `json:"here,omitempty"`
|
Here *float64 `json:"here,omitempty"`
|
||||||
State string `json:"state,omitempty"`
|
State string `json:"state,omitempty"`
|
||||||
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 T0: +2"
|
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 Y1 C2: +2"
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIDrillGroup struct {
|
type APIDrillGroup struct {
|
||||||
@@ -228,19 +226,19 @@ type APIWell struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type APIToolGroup struct {
|
type APIToolGroup struct {
|
||||||
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
|
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
|
||||||
StoreItemID int `json:"storeItemId"`
|
StoreItemID int `json:"storeItemId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Tier int `json:"tier"`
|
YieldLevel int `json:"yieldLevel"`
|
||||||
Count int `json:"count"`
|
CycleLevel int `json:"cycleLevel"`
|
||||||
CycleSecs int `json:"cycleSecs"`
|
Count int `json:"count"`
|
||||||
Yield float64 `json:"yield"`
|
CycleSecs float64 `json:"cycleSecs"`
|
||||||
Reach int `json:"reach"`
|
Yield float64 `json:"yield"`
|
||||||
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
|
Reach int `json:"reach"`
|
||||||
Free float64 `json:"free"`
|
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
|
||||||
Short bool `json:"short"`
|
Free float64 `json:"free"`
|
||||||
Blocked bool `json:"blocked"` // here is 0: the stake is refused
|
Short bool `json:"short"`
|
||||||
Attachments []string `json:"attachments"`
|
Blocked bool `json:"blocked"` // here is 0: the stake is refused
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIBetterWell struct {
|
type APIBetterWell struct {
|
||||||
@@ -334,14 +332,6 @@ func apiAmounts(rows []ResourceBalance) []APIAmount {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiGear(gear []*Attachment) []string {
|
|
||||||
out := []string{}
|
|
||||||
for _, a := range gear {
|
|
||||||
out = append(out, a.Name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiStake(st StakeRow, projected bool) APIStake {
|
func apiStake(st StakeRow, projected bool) APIStake {
|
||||||
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
|
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
|
||||||
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
|
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
|
||||||
@@ -415,8 +405,8 @@ func apiState(p *Page, extra ...*Page) *APIState {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
|
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
|
||||||
Image: itemImages + item.ImageAt(t.Tier), Tier: t.Tier, MaxTier: len(item.Upgrades),
|
Image: itemImages + item.Image, YieldLevel: t.YieldLevel, CycleLevel: t.CycleLevel,
|
||||||
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
|
Reach: item.ToolReach()}
|
||||||
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
|
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
|
||||||
if st := s.StakeForTool(t.ID); st != nil {
|
if st := s.StakeForTool(t.ID); st != nil {
|
||||||
tool.StakeID = st.ID
|
tool.StakeID = st.ID
|
||||||
@@ -453,8 +443,8 @@ func apiState(p *Page, extra ...*Page) *APIState {
|
|||||||
well := &APIWell{ID: w.ID, Resource: w.Resource, Rate: w.Rate, Idle: []APIToolGroup{}}
|
well := &APIWell{ID: w.ID, Resource: w.Resource, Rate: w.Rate, Idle: []APIToolGroup{}}
|
||||||
for _, g := range w.Idle {
|
for _, g := range w.Idle {
|
||||||
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
|
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
|
||||||
Tier: g.Tier, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield, Reach: max(1, g.Reach), Here: g.Here,
|
YieldLevel: g.YieldLevel, CycleLevel: g.CycleLevel, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield,
|
||||||
Free: g.Free, Short: g.Short(), Blocked: g.Blocked(), Attachments: apiGear(g.Gear)})
|
Reach: max(1, g.Reach), Here: g.Here, Free: g.Free, Short: g.Short(), Blocked: g.Blocked()})
|
||||||
}
|
}
|
||||||
if b := w.Better; b != nil {
|
if b := w.Better; b != nil {
|
||||||
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
|
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
|
||||||
@@ -614,8 +604,8 @@ type APIToast struct {
|
|||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIFX is what the web client plays effects from (fx.js): the action, the moons it mined
|
// APIFX is what the client plays effects from: the action, the moons it mined out and what a
|
||||||
// out and what a sale earned.
|
// sale earned.
|
||||||
type APIFX struct {
|
type APIFX struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Finished []string `json:"finished"`
|
Finished []string `json:"finished"`
|
||||||
@@ -641,7 +631,7 @@ func apiServerError(w http.ResponseWriter, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apiUser finds the player from the X-MM-User header or the mm_user cookie, or creates one
|
// apiUser finds the player from the X-MM-User header or the mm_user cookie, or creates one
|
||||||
// (as POST /session does). It echoes the id in the header and cookie; on a refusal it
|
// (rate-limited per IP). It echoes the id in the header and cookie; on a refusal it
|
||||||
// writes the error response and returns "".
|
// writes the error response and returns "".
|
||||||
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
|
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@@ -651,7 +641,15 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
|
|||||||
userID = c.Value
|
userID = c.Value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !uuidPattern.MatchString(userID) || !userExists(ctx, s.db, userID) {
|
exists := false
|
||||||
|
if uuidPattern.MatchString(userID) {
|
||||||
|
var err error
|
||||||
|
if exists, err = userExists(ctx, s.db, userID); err != nil {
|
||||||
|
apiServerError(w, err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
if !s.newPlayers.allow(r) {
|
if !s.newPlayers.allow(r) {
|
||||||
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
|
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
|
||||||
return "", false
|
return "", false
|
||||||
@@ -662,7 +660,10 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
created = true
|
created = true
|
||||||
} else if isBanned(ctx, s.db, userID) {
|
} else if banned, err := isBanned(ctx, s.db, userID); err != nil {
|
||||||
|
apiServerError(w, err)
|
||||||
|
return "", false
|
||||||
|
} else if banned {
|
||||||
apiError(w, http.StatusForbidden, "This account is suspended")
|
apiError(w, http.StatusForbidden, "This account is suspended")
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -687,6 +688,12 @@ func (s *server) apiPage(ctx context.Context, userID string, v View, get func(st
|
|||||||
|
|
||||||
// apiPageFor builds the page for a resolved view, with what the server loads for it.
|
// apiPageFor builds the page for a resolved view, with what the server loads for it.
|
||||||
func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, error) {
|
func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, error) {
|
||||||
|
if v.Section == "moons" {
|
||||||
|
// The moon view is the only one that shows a description (loadState leaves them out).
|
||||||
|
if err := loadMoonDescription(ctx, s.db, state, v.Moon); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
page := state.page(v)
|
page := state.page(v)
|
||||||
if err := s.loadRanking(ctx, page); err != nil {
|
if err := s.loadRanking(ctx, page); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -773,7 +780,7 @@ func (s *server) handleAPIState(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, APIResponse{OK: true, UserID: userID, New: created, State: state})
|
writeJSON(w, http.StatusOK, APIResponse{OK: true, UserID: userID, New: created, State: state})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAPIAction is POST /api/act/{action}: runs an action like POST /act/{action} and
|
// handleAPIAction is POST /api/act/{action}: runs an action (runAction) and
|
||||||
// answers with the toast and the state after it. A rule violation is a 422 (with the state).
|
// answers with the toast and the state after it. A rule violation is a 422 (with the state).
|
||||||
func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
|
||||||
get, err := apiParams(r)
|
get, err := apiParams(r)
|
||||||
|
|||||||
+85
-81
@@ -1,9 +1,9 @@
|
|||||||
# Moon Miners JSON API
|
# Moon Miners JSON API
|
||||||
|
|
||||||
For game clients other than the htmx page (the Defold client). Same state and rules as the
|
The game's API, for the Defold client. `GET /api/state` is built from the view models
|
||||||
web game: `GET /api/state` is built from the same view models as `GET /`, and
|
(`views.go`), and `POST /api/act/<action>` runs the game rules through `runAction`
|
||||||
`POST /api/act/<action>` runs the same code as `POST /act/<action>` (`runAction` in
|
(server.go), onboarding gates included. Code: `api.go`. "The web" below is the former htmx
|
||||||
server.go), onboarding gates included. Code: `api.go`.
|
client: the API keeps its texts and formats.
|
||||||
|
|
||||||
Conventions:
|
Conventions:
|
||||||
- Times are unix milliseconds (`null` when unset), durations seconds. `state.now` is the
|
- Times are unix milliseconds (`null` when unset), durations seconds. `state.now` is the
|
||||||
@@ -74,7 +74,7 @@ Core-loop actions and their params:
|
|||||||
|
|
||||||
| action | params | notes |
|
| 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 |
|
| `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. `count` (optional): a moon pack's size (`newMoon.grades[].packs`: 5, 10, 20 once the store is open) buys that many at once, at the pack's price, each moon at least the pack's floor; the view and `reveal` show the best of them |
|
||||||
| `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`) |
|
| `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 |
|
| `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: each time the smallest that fills what is left alone, else the biggest (as `stake`; the rest stay idle, refused if none fits) |
|
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well, until it has no room left: each time the smallest that fills what is left alone, else the biggest (as `stake`; the rest stay idle, refused if none fits) |
|
||||||
@@ -85,11 +85,8 @@ The other sections' actions:
|
|||||||
| action | params | notes |
|
| action | params | notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `buy-tool` | `id` (store item id) | response has `buyModal` |
|
| `buy-tool` | `id` (store item id) | response has `buyModal` |
|
||||||
| `upgrade` | `tool` (tool id) | `drill=` follows the copy upgraded |
|
| `upgrade` | `tool` (tool id), `track` (`yield` or `cycle`) | one level on that track of that tool (also while it mines: from its next drill) |
|
||||||
| `sell-tool` | `tool` | idle, nothing to claim, not the last tool (`noSale`) |
|
| `sell-tool` | `tool` | that tool: idle, nothing to claim, not the last tool (`noSale`) |
|
||||||
| `buy-attachment` | `item` (attachment name) | |
|
|
||||||
| `equip`, `unequip` | `item` (attachment name), `to` (tool id) | `drill=` follows the copy used |
|
|
||||||
| `sell-attachment` | `item` | a spare one |
|
|
||||||
| `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
|
| `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 | |
|
| `collect` | `job` (job id), or none for every finished batch | |
|
||||||
| `buy-slot` | – | refinery slot |
|
| `buy-slot` | – | refinery slot |
|
||||||
@@ -118,11 +115,14 @@ Responses:
|
|||||||
|
|
||||||
## GET /api/moon-settings
|
## GET /api/moon-settings
|
||||||
|
|
||||||
The procedural-moon look tuned by admins on `/utils/moons` (no player needed): the saved
|
The procedural-moon look shared by every moon (no player needed): the saved JSON object, or
|
||||||
JSON object, or `null` when none is saved. Merge it over the defaults (`DEFAULTS` in
|
`null` when none is saved. Merge it over the defaults (`DEFAULTS` in the Defold client's
|
||||||
`web/static/js/terrain.js`; unknown keys and wrong types are ignored, see
|
`modules/moon/terrain.lua`; unknown keys and wrong types are ignored, see
|
||||||
`mergeSettings`), then build each moon from its id like `terrain.js` does. Cache it for the
|
`merge_settings`), then build each moon from its id. Cache it for the session.
|
||||||
session; the web page also reads it once per page load.
|
|
||||||
|
`POST /api/moon-settings` saves it (a JSON object, up to 64 KB; 204): for a signed-in admin
|
||||||
|
(the `mm_admin` cookie), a player in `ADMIN_USER_IDS` (`X-MM-User`), or anyone with
|
||||||
|
`MM_DEV=1`; others get a 403.
|
||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
@@ -140,18 +140,18 @@ session; the web page also reads it once per page load.
|
|||||||
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
|
| `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=` |
|
| `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` | `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) |
|
||||||
| `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) |
|
| `tools` | every tool: `{id, storeItemId, name, code, image, yieldLevel, cycleLevel, reach, cycleSecs, yield, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL; `yieldLevel`/`cycleLevel` its levels on its two upgrade tracks (0: the base tool); `cycleSecs`/`yield` at those levels with advisors and "all" live events; `cycleSecs` is in tenths of a second, e.g. `2.6`) |
|
||||||
| `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) |
|
| `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` |
|
| `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: +2 of 3, ready"; a tool with upgrades has its levels after the name, "Prospector Drone PD-1 Y1 C2: …"); `label` is the web's compact name of the well's drills ("PD-1", "PD-1 Y1 C2", "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}?}` |
|
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, yieldLevel, cycleLevel, count, cycleSecs, yield, reach, here, free, short, blocked}], 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`) |
|
| `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`) |
|
||||||
| `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 `""`) |
|
| `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` |
|
| `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) |
|
| `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) |
|
| `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%") |
|
| `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"` |
|
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, 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) |
|
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tracks: [{kind, name, levels: [{level, value, price, parts: [{name, amount}]}]}]}], 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 `tracks` are its yield track then its cycle track, each level's `value` the claim per cycle or the cycle in seconds from that level on, before bonuses (`reach` never changes); an advisor's `effectText` e.g. "+8% sell prices"; `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"; `""` 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=` |
|
| `catalogVersion` | a hash of the catalog, for `catalog=` |
|
||||||
|
|
||||||
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
|
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
|
||||||
@@ -165,43 +165,46 @@ short code, listed with each field, and the web's text next to it.
|
|||||||
### equipment (`s=equipment`)
|
### equipment (`s=equipment`)
|
||||||
|
|
||||||
```
|
```
|
||||||
{tools: [{id, storeItemId, name, code, image, tier, maxTier, count, idle, reach, cycleSecs, yield,
|
{tools: [{id, storeItemId, name, code, image, yieldLevel, cycleLevel, count, idle, reach, cycleSecs, yield,
|
||||||
attachments, slots, freeSlots, selected, stake, next, preview, ladder, resale, noSale, bonus, gear}],
|
selected, stake, tracks, ladderKey, resale, noSale, noSaleText, bonus}],
|
||||||
|
ladders: {<ladderKey>: {yield: [steps], cycle: [steps]}},
|
||||||
drill}
|
drill}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `image`: the tool's art at its tier, like `state.tools`.
|
- Every tool is its own row, copies too: `count` is 1, `idle` 1 when it isn't mining, `stake`
|
||||||
- Copies of a tool (same type, tier and attachments) are one row: `count` copies, `idle`
|
its drill.
|
||||||
of them idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill.
|
- `tracks`: the tool's two upgrade tracks, yield then cycle: `{kind, name, level, max, value,
|
||||||
- `next`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}],
|
next}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds
|
||||||
canAfford, blocked, short, blockedText, shortText}`; `blocked` is `credits` or `parts`
|
per cycle); values are a stake's now, with the player's bonuses and live events. Upgrade
|
||||||
(`""` when it can be bought), `short` the credits missing; `blockedText` the button's
|
with `tool=<id>&track=<kind>`.
|
||||||
tooltip ("Need ₥750 more", "Need 2 Dusk Alloy (you have 0). Make it in the Refinery"),
|
- `next`: the next level, `null` at the top: `{level, value, label, price, parts: [{name,
|
||||||
`shortText` the line under it when credits are short.
|
need, have}], canAfford, blocked, short, blockedText, shortText, rateFrom, rateTo, cost,
|
||||||
- `preview`: what the next tier changes, with the player's bonuses now: `{tier, yieldFrom,
|
paybackSecs}`; `label` the track name and the level it buys ("Twin rotors 2"); `blocked`
|
||||||
yieldTo, secsFrom, secsTo, reachFrom, reachTo, rateFrom, rateTo, cost, paybackSecs}`
|
is `credits` or `parts` (`""` when it can be bought), `short` the credits missing;
|
||||||
(`rate` is credits per hour; `paybackSecs` null if it never pays back).
|
`blockedText` why the button is disabled ("Need ₥750 more", "Need 2 Dusk Alloy (you have
|
||||||
- `ladder`: every tier `[{tier, cycleSecs, yield, reach, rate, current, next}]`.
|
0). Make it in the Refinery"), `shortText` the line under it when credits are short;
|
||||||
- `resale`: what it sells for; `noSale`: `only-tool`, `claim-first`, `mining` or `""`,
|
`rateFrom`/`rateTo` credits per hour now and after it, `cost` its price plus the parts at
|
||||||
`noSaleText` its text ("Claim its haul first").
|
sell value, `paybackSecs` null if it never pays back.
|
||||||
- `bonus`: `{speed, yield}` from advisors and its attachments (capped); `gear` the
|
- `ladders`: each track's ladder, sent once for every tool with the same store item and
|
||||||
attachments `[{name, image, effect}]`.
|
levels: `ladders[ladderKey][kind]` (`ladderKey` is `"<storeItemId>:<yieldLevel>:<cycleLevel>"`)
|
||||||
- `drill` (with `drill=<tool id>`, once `unlocked.equipment`, else `null`): the drill panel `{toolId, slots: [{n,
|
is every level from 0 `[{level, value, rate, price, parts: [{name, need, have}], current,
|
||||||
attachment}], spares: [{name, count, cycleSecs, yield, canEquip, why, whyText, price, resale,
|
next}]` (the other track as it is; `price` 0 for level 0).
|
||||||
confirm}], mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo, ladder,
|
- `resale`: what it sells for (its price and its levels' prices × `rules.equipmentResale`);
|
||||||
gear}`. `attachment` is `""` for an empty slot (unequip with `item=<name>&to=<toolId>`); a
|
`noSale`: `only-tool`, `claim-first`, `mining` or `""`, `noSaleText` its text ("Claim its
|
||||||
spare's `cycleSecs`/`yield` are the drill's with it equipped, `why` is `fitted` (one is on
|
haul first").
|
||||||
already) or `full`; `confirm` (panel) is the sell-tool dialog, a spare's the
|
- `bonus`: `{speed, yield}` from advisors (capped).
|
||||||
sell-attachment one. `gear[].effectText` e.g. "+20% speed". While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy
|
- `drill` (with `drill=<tool id>`, once `unlocked.equipment`, else `null`): the drill panel
|
||||||
and `deployTo` its best well (a suggestion, as in `buyModal`). Its upgrade, slots, spares
|
`{toolId, mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo}`; its
|
||||||
and Sell drill are Store features (`upgrade`, `equip`, `unequip`, `sell-tool` are refused
|
tracks and ladders are the selected row's (`selected`). `confirm` is the sell-tool dialog.
|
||||||
before it): show them once `unlocked.store`; before that the panel is its stats and `ladder`.
|
While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy and
|
||||||
|
`deployTo` its best well (a suggestion, as in `buyModal`). Its upgrade buttons and Sell
|
||||||
|
drill are Store features (`upgrade` and `sell-tool` are refused before it): show them once
|
||||||
|
`unlocked.store`; before that the panel is its stats and the ladders.
|
||||||
|
|
||||||
### store (`s=store`)
|
### store (`s=store`)
|
||||||
|
|
||||||
```
|
```
|
||||||
{tools: [{storeItemId, name, code, image, description, price, canAfford, owned, yield, cycleSecs, reach, rate, bestRate, whyText}],
|
{tools: [{storeItemId, name, code, image, description, price, canAfford, owned, yield, cycleSecs, reach, rate, bestRate, whyText}]}
|
||||||
attachments: [{name, image, description, effect: {speed, yield}, effectText, price, canAfford, spare, equipped, resale, whyText}]}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`whyText`: the disabled Buy button's tooltip ("Need ₥1,400 more"), `""` when affordable.
|
`whyText`: the disabled Buy button's tooltip ("Need ₥1,400 more"), `""` when affordable.
|
||||||
@@ -297,7 +300,9 @@ button), `credits` ("Need ₥X more", the button disabled) or `""`.
|
|||||||
|
|
||||||
`{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId,
|
`{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId,
|
||||||
moonName, wellId, resource, supply, credits, perMin, idle, reason}]}`: up to 3 wells to
|
moonName, wellId, resource, supply, credits, perMin, idle, reason}]}`: up to 3 wells to
|
||||||
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first;
|
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first; `credits`
|
||||||
|
is a cycle's take after the drills already on the moon, and wells where it would get nothing
|
||||||
|
(stake refused) are left out (`allEmpty` when none is left);
|
||||||
`reason` `best-value`, `most-supply`, `no-drill` or `shared`, `reasonText` its text
|
`reason` `best-value`, `most-supply`, `no-drill` or `shared`, `reasonText` its text
|
||||||
("Highest value").
|
("Highest value").
|
||||||
|
|
||||||
@@ -324,8 +329,8 @@ is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkl
|
|||||||
|
|
||||||
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
|
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
|
||||||
the web's title, question, OK button and whether it's destructive, e.g.
|
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
|
`{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok":
|
||||||
attachments go back to your inventory.", "ok": "Sell for ₥750", "danger": true}`. Texts with
|
"Sell for ₥980", "danger": true}`. Texts with
|
||||||
a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.now`.
|
a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.now`.
|
||||||
|
|
||||||
Not sent (the client words them from the numbers, like the web's templates):
|
Not sent (the client words them from the numbers, like the web's templates):
|
||||||
@@ -333,12 +338,13 @@ Not sent (the client words them from the numbers, like the web's templates):
|
|||||||
supply" (`over`).
|
supply" (`over`).
|
||||||
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
|
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
|
||||||
current drills" (`well.idle[].short`, `here`, `free`).
|
current drills" (`well.idle[].short`, `here`, `free`).
|
||||||
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
|
- upgrade payback ("pays back in 2h 5m"), "Max level", empty states ("No idle tools.").
|
||||||
|
- a tool's levels after its name, " Y2 C1" (none for a base tool).
|
||||||
|
|
||||||
The web's formats:
|
The web's formats:
|
||||||
- credits: `₥` + the amount with thousands commas and up to 2 decimals (`₥1,234.5`);
|
- credits: `₥` + the amount with thousands commas and up to 2 decimals (`₥1,234.5`);
|
||||||
amounts the same without `₥`. Compact (per hour): `₥1.2M`, `₥45k`, `₥1.5k`, else whole.
|
amounts the same without `₥`. Compact (per hour): `₥1.2M`, `₥45k`, `₥1.5k`, else whole.
|
||||||
- cycles: `3s`, `2m`, `2m 30s`; longer spans `2h 5m`. Countdowns `m:ss`, `h:mm:ss`.
|
- cycles: `3s`, `2.6s` (tenths under a minute), `2m`, `2m 30s`; longer spans `2h 5m`. Countdowns `m:ss`, `h:mm:ss`.
|
||||||
- banner time left: `2d 4h left`, `3h 20m left`, `45m left`.
|
- banner time left: `2d 4h left`, `3h 20m left`, `45m left`.
|
||||||
- bonuses: `+` and the percent with one decimal at most (`+10%`, `+12.5%`).
|
- bonuses: `+` and the percent with one decimal at most (`+10%`, `+12.5%`).
|
||||||
|
|
||||||
@@ -354,8 +360,8 @@ The client may show results before the server answers; the next state replaces t
|
|||||||
recompute: the moon's stakes, oldest `start` first (ties in `stakes` order), are claimed
|
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
|
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
|
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
|
own well (if not empty) then the next wells in `moon.wells` order (wrapping round), each
|
||||||
`moon.wells` order), up to `reach` wells, taking from each in turn as much as is left
|
if it has something left, 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
|
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
|
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
|
taking from the well) that leaves it at 0. A deploy card's `here` is what a new stake
|
||||||
@@ -412,16 +418,16 @@ Content-Type: application/json
|
|||||||
"past": [],
|
"past": [],
|
||||||
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
|
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
|
||||||
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
|
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
|
||||||
"image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5,
|
"image": "/static/assets/items/shovel.png", "yieldLevel": 0, "cycleLevel": 0,
|
||||||
"reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}],
|
"reach": 1, "cycleSecs": 3, "yield": 4, "stakeId": "efc5071d-…"}],
|
||||||
"stakes": [{"id": "efc5071d-…", "moonId": "54daa782-…", "wellId": "b9d643c4-…", "toolId": "0fbfcb4f-…",
|
"stakes": [{"id": "efc5071d-…", "moonId": "54daa782-…", "wellId": "b9d643c4-…", "toolId": "0fbfcb4f-…",
|
||||||
"resource": "Nebulance", "amount": 2, "reach": 1, "start": 1792391815259, "end": 1792391819259,
|
"resource": "Nebulance", "amount": 2, "reach": 1, "start": 1792391815259, "end": 1792391819259,
|
||||||
"durationSecs": 4, "ready": false, "take": 2}],
|
"durationSecs": 4, "ready": false, "take": 2}],
|
||||||
"moon": {"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "description": "A bleached mauve satellite …",
|
"moon": {"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "description": "A bleached mauve satellite …",
|
||||||
"rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70}, "finished": false, "ready": 0, "nextEnd": 1792391819259,
|
"rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70}, "finished": false, "ready": 0, "nextEnd": 1792391819259,
|
||||||
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47,
|
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47,
|
||||||
"drills": [{"id": "efc5071d-…", "…": "…", "gets": 2, "here": 2, "state": "ok", "fate": "Prospector Drone PD-1 T0: +2"}],
|
"drills": [{"id": "efc5071d-…", "…": "…", "gets": 2, "here": 2, "state": "ok", "fate": "Prospector Drone PD-1: +2"}],
|
||||||
"ready": 0, "readyAmount": 0, "nextEnd": 1792391819259, "cap": 47, "label": "PD-1 T0", "wide": [],
|
"ready": 0, "readyAmount": 0, "nextEnd": 1792391819259, "cap": 47, "label": "PD-1", "wide": [],
|
||||||
"selected": true, "redrill": 0, "demand": 2, "remain": 45, "dryAfter": 0, "dryAt": null}],
|
"selected": true, "redrill": 0, "demand": 2, "remain": 45, "dryAfter": 0, "dryAt": null}],
|
||||||
"wide": null},
|
"wide": null},
|
||||||
"well": {"id": "b9d643c4-…", "resource": "Nebulance", "rate": 11.157, "idle": [], "better": null},
|
"well": {"id": "b9d643c4-…", "resource": "Nebulance", "rate": 11.157, "idle": [], "better": null},
|
||||||
@@ -431,8 +437,8 @@ Content-Type: application/json
|
|||||||
"selected": "Azurium", "total": 0},
|
"selected": "Azurium", "total": 0},
|
||||||
"banner": [],
|
"banner": [],
|
||||||
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
|
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
|
||||||
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"},
|
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "…": "…"},
|
||||||
"catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
|
"catalog": {"tools": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
|
||||||
"catalogVersion": "9adb4eabab929f5d"
|
"catalogVersion": "9adb4eabab929f5d"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,20 +451,21 @@ From a live server (long ids and arrays shortened).
|
|||||||
`GET /api/state?s=equipment&drill=7e40…`, `state.equipment`:
|
`GET /api/state?s=equipment&drill=7e40…`, `state.equipment`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"tools": [{"id": "7e40…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
|
{"tools": [{"id": "13b3…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
|
||||||
"image": "/static/assets/items/shovel-t1.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
|
"image": "/static/assets/items/shovel.png", "yieldLevel": 1, "cycleLevel": 2, "count": 1, "idle": 1,
|
||||||
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true,
|
"reach": 1, "cycleSecs": 2.2, "yield": 5, "selected": true, "stake": null,
|
||||||
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0},
|
"tracks": [{"kind": "yield", "name": "Twin rotors", "level": 1, "max": 4, "value": 5,
|
||||||
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1,
|
"next": {"level": 2, "value": 6, "label": "Twin rotors 2", "price": 320, "parts": [],
|
||||||
"rateFrom": 24015.57, "rateTo": 32020.76, "cost": 900, "paybackSecs": 404.7},
|
"canAfford": true, "blocked": "", "short": 0, "blockedText": "", "shortText": "",
|
||||||
"ladder": [{"tier": 0, "cycleSecs": 3, "yield": 2, "reach": 1, "rate": 16010.38, "current": false, "next": false}, "…"],
|
"rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7}},
|
||||||
"resale": 1175, "noSale": "", "bonus": {"speed": 1.2, "yield": 1},
|
{"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…"}],
|
||||||
"gear": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png", "effect": {"speed": 1.2, "yield": 1}}]}],
|
"ladderKey": "1:1:2", "resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}],
|
||||||
"drill": {"toolId": "7e40…", "slots": [{"n": 1, "attachment": "Turbo Core"}, {"n": 2, "attachment": ""}],
|
"ladders": {"1:1:2": {"yield": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false},
|
||||||
"spares": [{"name": "Diamond Bit", "count": 1, "cycleSecs": 3, "yield": 2.3, "canEquip": true, "why": "",
|
{"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"],
|
||||||
"price": 4000, "resale": 2000}],
|
"cycle": ["…"]}},
|
||||||
"mining": false, "resale": 750, "noSale": "", "idleSame": 0, "idleBest": "", "deployTo": null,
|
"drill": {"toolId": "13b3…", "mining": false, "resale": 980, "noSale": "", "noSaleText": "",
|
||||||
"ladder": ["…"], "gear": ["…"]}}
|
"confirm": {"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok": "Sell for ₥980", "danger": true},
|
||||||
|
"idleSame": 0, "idleBest": "", "deployTo": null}}
|
||||||
```
|
```
|
||||||
|
|
||||||
`s=store`, `state.store`:
|
`s=store`, `state.store`:
|
||||||
@@ -466,10 +473,7 @@ From a live server (long ids and arrays shortened).
|
|||||||
```json
|
```json
|
||||||
{"tools": [{"storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", "image": "/static/assets/items/shovel.png",
|
{"tools": [{"storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", "image": "/static/assets/items/shovel.png",
|
||||||
"description": "A hovering scout drone that skims ore off the surface.", "price": 1500, "canAfford": true,
|
"description": "A hovering scout drone that skims ore off the surface.", "price": 1500, "canAfford": true,
|
||||||
"owned": 1, "yield": 2, "cycleSecs": 4, "reach": 1, "rate": 12007.78, "bestRate": 12007.78}, "…"],
|
"owned": 1, "yield": 4, "cycleSecs": 3, "reach": 1, "rate": 12007.78, "bestRate": 12007.78}, "…"]}
|
||||||
"attachments": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png",
|
|
||||||
"description": "An overdriven motor core. Runs hot, cycles fast.", "effect": {"speed": 1.2, "yield": 1},
|
|
||||||
"price": 3000, "canAfford": true, "spare": 0, "equipped": 0, "resale": 1500}, "…"]}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`s=refinery`, `state.refinery`:
|
`s=refinery`, `state.refinery`:
|
||||||
|
|||||||
+191
-244
@@ -9,33 +9,31 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Image URLs, as the templates build them.
|
// Image URLs, as the templates build them.
|
||||||
const (
|
const (
|
||||||
itemImages = "/static/assets/items/"
|
itemImages = "/static/assets/items/"
|
||||||
attachmentImages = "/static/assets/attachments/"
|
advisorImages = "/static/assets/advisors/"
|
||||||
advisorImages = "/static/assets/advisors/"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ------------------------------------------------------------------ always present
|
// ------------------------------------------------------------------ always present
|
||||||
|
|
||||||
// APICatalog is the static game data: every tool, attachment, advisor, resource and
|
// APICatalog is the static game data: every tool (with its upgrade tracks), advisor,
|
||||||
// refined item with its image and color.
|
// resource and refined item with its image and color.
|
||||||
type APICatalog struct {
|
type APICatalog struct {
|
||||||
Tools []APICatalogTool `json:"tools"`
|
Tools []APICatalogTool `json:"tools"`
|
||||||
Attachments []APICatalogAttachment `json:"attachments"`
|
Advisors []APICatalogAdvisor `json:"advisors"`
|
||||||
Advisors []APICatalogAdvisor `json:"advisors"`
|
Resources []APICatalogResource `json:"resources"`
|
||||||
Resources []APICatalogResource `json:"resources"`
|
Refined []APICatalogRefined `json:"refined"`
|
||||||
Refined []APICatalogRefined `json:"refined"`
|
Reach []APIReachText `json:"reach"` // the web's wording of a reach, 1 up to the whole moon
|
||||||
Reach []APIReachText `json:"reach"` // the web's wording of a reach, 1 up to the whole moon
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIReachText is how the web words a tool's reach (wells one cycle mines): label on cards
|
// APIReachText is how the web words a tool's reach (wells one cycle mines): label on cards
|
||||||
// ("Mines 3 wells", "Strips the whole moon"; "" for one), short on deploy cards ("3 wells",
|
// ("Mines 3 wells", "Strips the whole moon"; "" for one), short on deploy cards ("3 wells",
|
||||||
// "whole moon"; "" for one), wells in the tier ladder ("1 well", "whole moon"). A reach at or
|
// "whole moon"; "" for one), wells ("1 well", "whole moon"). A reach at or
|
||||||
// above the last entry's is the whole moon.
|
// above the last entry's is the whole moon.
|
||||||
type APIReachText struct {
|
type APIReachText struct {
|
||||||
Reach int `json:"reach"`
|
Reach int `json:"reach"`
|
||||||
@@ -45,27 +43,31 @@ type APIReachText struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type APICatalogTool struct {
|
type APICatalogTool struct {
|
||||||
StoreItemID int `json:"storeItemId"`
|
StoreItemID int `json:"storeItemId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Code string `json:"code"` // e.g. "PD-1"
|
Code string `json:"code"` // e.g. "PD-1"
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
Yield float64 `json:"yield"` // base, tier 0
|
Yield float64 `json:"yield"` // base (level 0)
|
||||||
CycleSecs int `json:"cycleSecs"` // base
|
CycleSecs float64 `json:"cycleSecs"` // base
|
||||||
Reach int `json:"reach"` // tier 0
|
Reach int `json:"reach"`
|
||||||
Tiers []APITierUp `json:"tiers"` // upgrades, tier 1 on
|
Tracks []APICatalogTrack `json:"tracks"` // yield, then cycle
|
||||||
}
|
}
|
||||||
|
|
||||||
type APITierUp struct {
|
// APICatalogTrack is one of a tool's upgrade tracks: its levels from 1, each setting the
|
||||||
Tier int `json:"tier"`
|
// track's value (yield per cycle, or cycle seconds) before bonuses.
|
||||||
Price float64 `json:"price"`
|
type APICatalogTrack struct {
|
||||||
ClaimBoost float64 `json:"claimBoost"`
|
Kind string `json:"kind"` // yield, cycle (upgrade with track=<kind>)
|
||||||
Speed float64 `json:"speed"` // cycle speed multiplier at this tier (1: none)
|
Name string `json:"name"`
|
||||||
CycleSecs int `json:"cycleSecs"` // the tier's cycle before bonuses
|
Levels []APITrackLevel `json:"levels"`
|
||||||
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 APITrackLevel struct {
|
||||||
|
Level int `json:"level"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Parts []APIAmount `json:"parts"` // refined items it uses up
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIEffect struct {
|
type APIEffect struct {
|
||||||
@@ -75,16 +77,6 @@ type APIEffect struct {
|
|||||||
Refine float64 `json:"refine,omitempty"`
|
Refine float64 `json:"refine,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APICatalogAttachment struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
Resale float64 `json:"resale"`
|
|
||||||
Effect APIEffect `json:"effect"`
|
|
||||||
EffectText string `json:"effectText"` // e.g. "+20% speed"
|
|
||||||
}
|
|
||||||
|
|
||||||
type APICatalogAdvisor struct {
|
type APICatalogAdvisor struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -141,27 +133,23 @@ func apiNeed(need map[string]float64) []APIAmount {
|
|||||||
|
|
||||||
func apiCatalog(s *State) *APICatalog {
|
func apiCatalog(s *State) *APICatalog {
|
||||||
c := cfg()
|
c := cfg()
|
||||||
cat := &APICatalog{Tools: []APICatalogTool{}, Attachments: []APICatalogAttachment{}, Advisors: []APICatalogAdvisor{},
|
cat := &APICatalog{Tools: []APICatalogTool{}, Advisors: []APICatalogAdvisor{},
|
||||||
Resources: []APICatalogResource{}, Refined: []APICatalogRefined{}, Reach: []APIReachText{}}
|
Resources: []APICatalogResource{}, Refined: []APICatalogRefined{}, Reach: []APIReachText{}}
|
||||||
for i := range s.Config.StoreItems {
|
for i := range s.Config.StoreItems {
|
||||||
item := &s.Config.StoreItems[i]
|
item := &s.Config.StoreItems[i]
|
||||||
t := APICatalogTool{StoreItemID: item.ID, Name: item.Name, Code: toolCode(item.Name), Image: itemImages + item.Image,
|
t := APICatalogTool{StoreItemID: item.ID, Name: item.Name, Code: toolCode(item.Name), Image: itemImages + item.Image,
|
||||||
Description: item.Description, Price: item.Price, Yield: item.ClaimAmount, CycleSecs: item.CompletionTimeInSecs,
|
Description: item.Description, Price: item.Price, Yield: item.ClaimAmount,
|
||||||
Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}}
|
CycleSecs: float64(item.CompletionTimeInSecs), Reach: item.ToolReach(), Tracks: []APICatalogTrack{}}
|
||||||
for _, u := range item.Upgrades {
|
for _, kind := range trackKinds {
|
||||||
t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost,
|
tr := item.Track(kind)
|
||||||
Speed: max(1, u.Speed), CycleSecs: item.ToolSecs(u.Tier),
|
ct := APICatalogTrack{Kind: kind, Name: tr.Name, Levels: []APITrackLevel{}}
|
||||||
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier))})
|
for n, l := range tr.Levels {
|
||||||
t.Tiers[len(t.Tiers)-1].Image = itemImages + item.ImageAt(u.Tier)
|
ct.Levels = append(ct.Levels, APITrackLevel{Level: n + 1, Value: l.Value, Price: l.Price, Parts: apiNeed(l.Parts)})
|
||||||
|
}
|
||||||
|
t.Tracks = append(t.Tracks, ct)
|
||||||
}
|
}
|
||||||
cat.Tools = append(cat.Tools, t)
|
cat.Tools = append(cat.Tools, t)
|
||||||
}
|
}
|
||||||
for i := range c.Attachments.Items {
|
|
||||||
a := &c.Attachments.Items[i]
|
|
||||||
cat.Attachments = append(cat.Attachments, APICatalogAttachment{Name: a.Name, Image: attachmentImages + a.Image,
|
|
||||||
Description: a.Description, Price: a.Price, Resale: a.Resale(), Effect: effectOf(a.Speed, a.Yield, 0, 0),
|
|
||||||
EffectText: a.Effect()})
|
|
||||||
}
|
|
||||||
for i := range c.Advisors.Items {
|
for i := range c.Advisors.Items {
|
||||||
a := &c.Advisors.Items[i]
|
a := &c.Advisors.Items[i]
|
||||||
cat.Advisors = append(cat.Advisors, APICatalogAdvisor{Name: a.Name, Title: a.Title, Image: advisorImages + a.Image,
|
cat.Advisors = append(cat.Advisors, APICatalogAdvisor{Name: a.Name, Title: a.Title, Image: advisorImages + a.Image,
|
||||||
@@ -210,7 +198,6 @@ type APIConfirm struct {
|
|||||||
type APIRules struct {
|
type APIRules struct {
|
||||||
WellSupplyMax float64 `json:"wellSupplyMax"` // the default grade's biggest well (each moon has its maxSupply)
|
WellSupplyMax float64 `json:"wellSupplyMax"` // 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
|
MaxSupply float64 `json:"maxSupply"` // the scale of the web's supply bars: the selected moon's grade
|
||||||
SlotsPerTool int `json:"slotsPerTool"`
|
|
||||||
WellsMin int `json:"wellsMin"`
|
WellsMin int `json:"wellsMin"`
|
||||||
WellsMax int `json:"wellsMax"`
|
WellsMax int `json:"wellsMax"`
|
||||||
Pressure APIPressure `json:"pressure"`
|
Pressure APIPressure `json:"pressure"`
|
||||||
@@ -218,7 +205,7 @@ type APIRules struct {
|
|||||||
MoonResale MoonResale `json:"moonResale"`
|
MoonResale MoonResale `json:"moonResale"`
|
||||||
EquipmentResale float64 `json:"equipmentResale"` // factor of the credit price
|
EquipmentResale float64 `json:"equipmentResale"` // factor of the credit price
|
||||||
BonusCaps BonusCaps `json:"bonusCaps"`
|
BonusCaps BonusCaps `json:"bonusCaps"`
|
||||||
Bonus APIEffect `json:"bonus"` // the advisors' bonus now, capped (tools add attachments)
|
Bonus APIEffect `json:"bonus"` // the advisors' bonus now, capped
|
||||||
Boosts map[string]APIMap `json:"boosts"` // live event multipliers: price, cycle, yield, by resource or "all"
|
Boosts map[string]APIMap `json:"boosts"` // live event multipliers: price, cycle, yield, by resource or "all"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +228,7 @@ func apiRules(p *Page) *APIRules {
|
|||||||
s, c := p.S, cfg()
|
s, c := p.S, cfg()
|
||||||
b := s.Bonus()
|
b := s.Bonus()
|
||||||
lo, hi := s.wellCountRange()
|
lo, hi := s.wellCountRange()
|
||||||
r := &APIRules{WellSupplyMax: s.MoonGrade().WellSupplyMax, MaxSupply: p.MaxSupply, SlotsPerTool: c.Attachments.SlotsPerTool,
|
r := &APIRules{WellSupplyMax: s.MoonGrade().WellSupplyMax, MaxSupply: p.MaxSupply,
|
||||||
WellsMin: lo, WellsMax: hi,
|
WellsMin: lo, WellsMax: hi,
|
||||||
Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale,
|
Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale,
|
||||||
HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale},
|
HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale},
|
||||||
@@ -317,47 +304,51 @@ type APILiveEvent struct {
|
|||||||
// ------------------------------------------------------------------ equipment
|
// ------------------------------------------------------------------ equipment
|
||||||
|
|
||||||
type APIEquipment struct {
|
type APIEquipment struct {
|
||||||
Tools []APIToolRow `json:"tools"` // copies of a tool (same type, tier, attachments) grouped
|
Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped
|
||||||
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
|
// The upgrade ladders by ladderKey (store item and levels: every tool with those has
|
||||||
|
// the same), each track's by kind; a tool row names its own (ladderKey).
|
||||||
|
Ladders map[string]map[string][]APILadderStep `json:"ladders"`
|
||||||
|
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIToolRow struct {
|
type APIToolRow struct {
|
||||||
ID string `json:"id"` // the copy shown (selected, else an idle one, else the first to finish)
|
ID string `json:"id"` // the copy shown (selected, else an idle one, else the first to finish)
|
||||||
StoreItemID int `json:"storeItemId"`
|
StoreItemID int `json:"storeItemId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Tier int `json:"tier"`
|
YieldLevel int `json:"yieldLevel"`
|
||||||
MaxTier int `json:"maxTier"`
|
CycleLevel int `json:"cycleLevel"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Idle int `json:"idle"`
|
Idle int `json:"idle"`
|
||||||
Reach int `json:"reach"`
|
Reach int `json:"reach"`
|
||||||
CycleSecs int `json:"cycleSecs"`
|
CycleSecs float64 `json:"cycleSecs"`
|
||||||
Yield float64 `json:"yield"`
|
Yield float64 `json:"yield"`
|
||||||
Attachments []string `json:"attachments"`
|
Selected bool `json:"selected"`
|
||||||
Slots int `json:"slots"`
|
Stake *APIStake `json:"stake"` // the shown copy's drill
|
||||||
FreeSlots int `json:"freeSlots"`
|
Tracks []APITrack `json:"tracks"` // yield, then cycle
|
||||||
Selected bool `json:"selected"`
|
LadderKey string `json:"ladderKey"` // its tracks' ladders in equipment.ladders
|
||||||
Stake *APIStake `json:"stake"` // the shown copy's drill
|
Resale float64 `json:"resale"`
|
||||||
Next *APINextTier `json:"next"` // null at the max tier
|
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
|
||||||
Preview *APIPreview `json:"preview"`
|
NoSaleText string `json:"noSaleText"`
|
||||||
Ladder []APITierRow `json:"ladder"`
|
Bonus APIEffect `json:"bonus"` // advisors, capped
|
||||||
Resale float64 `json:"resale"`
|
|
||||||
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
|
|
||||||
NoSaleText string `json:"noSaleText"`
|
|
||||||
Bonus APIEffect `json:"bonus"` // advisors and its attachments, capped
|
|
||||||
Gear []APIGearDetail `json:"gear"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIGearDetail struct {
|
// APITrack is one of a tool's upgrade tracks: values are a stake's now (yield per cycle,
|
||||||
Name string `json:"name"`
|
// or the cycle in seconds), with bonuses and live events.
|
||||||
Image string `json:"image"`
|
type APITrack struct {
|
||||||
Effect APIEffect `json:"effect"`
|
Kind string `json:"kind"` // yield, cycle: upgrade with tool=<id>&track=<kind>
|
||||||
EffectText string `json:"effectText"`
|
Name string `json:"name"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
Max int `json:"max"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Next *APINextLevel `json:"next"` // null at the top level
|
||||||
}
|
}
|
||||||
|
|
||||||
type APINextTier struct {
|
type APINextLevel struct {
|
||||||
Tier int `json:"tier"`
|
Level int `json:"level"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Label string `json:"label"` // the button's track name and level, "Twin rotors 2"
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
Parts []APIPart `json:"parts"`
|
Parts []APIPart `json:"parts"`
|
||||||
CanAfford bool `json:"canAfford"` // credits and parts
|
CanAfford bool `json:"canAfford"` // credits and parts
|
||||||
@@ -365,8 +356,12 @@ type APINextTier struct {
|
|||||||
Short float64 `json:"short"` // credits missing
|
Short float64 `json:"short"` // credits missing
|
||||||
// The web's texts: why the upgrade button is disabled (its tooltip) and the line under it
|
// The web's texts: why the upgrade button is disabled (its tooltip) and the line under it
|
||||||
// when credits are short ("Need ₥X more").
|
// when credits are short ("Need ₥X more").
|
||||||
BlockedText string `json:"blockedText"`
|
BlockedText string `json:"blockedText"`
|
||||||
ShortText string `json:"shortText"`
|
ShortText string `json:"shortText"`
|
||||||
|
RateFrom float64 `json:"rateFrom"` // credits per hour now and after it
|
||||||
|
RateTo float64 `json:"rateTo"`
|
||||||
|
Cost float64 `json:"cost"` // price plus the parts at sell value
|
||||||
|
PaybackSecs *float64 `json:"paybackSecs"` // null if it never pays back
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIPart struct {
|
type APIPart struct {
|
||||||
@@ -375,148 +370,106 @@ type APIPart struct {
|
|||||||
Have float64 `json:"have"`
|
Have float64 `json:"have"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIPreview struct {
|
type APILadderStep struct {
|
||||||
Tier int `json:"tier"`
|
Level int `json:"level"`
|
||||||
YieldFrom float64 `json:"yieldFrom"`
|
Value float64 `json:"value"`
|
||||||
YieldTo float64 `json:"yieldTo"`
|
Rate float64 `json:"rate"` // credits per hour
|
||||||
SecsFrom int `json:"secsFrom"`
|
Price float64 `json:"price"` // 0 for level 0
|
||||||
SecsTo int `json:"secsTo"`
|
Parts []APIPart `json:"parts"`
|
||||||
ReachFrom int `json:"reachFrom"`
|
Current bool `json:"current"`
|
||||||
ReachTo int `json:"reachTo"`
|
Next bool `json:"next"`
|
||||||
RateFrom float64 `json:"rateFrom"` // credits per hour
|
|
||||||
RateTo float64 `json:"rateTo"`
|
|
||||||
Cost float64 `json:"cost"` // price plus the parts at sell value
|
|
||||||
PaybackSecs *float64 `json:"paybackSecs"` // null if it never pays back
|
|
||||||
}
|
|
||||||
|
|
||||||
type APITierRow struct {
|
|
||||||
Tier int `json:"tier"`
|
|
||||||
CycleSecs int `json:"cycleSecs"`
|
|
||||||
Yield float64 `json:"yield"`
|
|
||||||
Reach int `json:"reach"`
|
|
||||||
Rate float64 `json:"rate"` // credits per hour
|
|
||||||
Current bool `json:"current"`
|
|
||||||
Next bool `json:"next"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIDrillPanel struct {
|
type APIDrillPanel struct {
|
||||||
ToolID string `json:"toolId"`
|
ToolID string `json:"toolId"`
|
||||||
Slots []APISlot `json:"slots"`
|
Mining bool `json:"mining"`
|
||||||
Spares []APISpare `json:"spares"`
|
Resale float64 `json:"resale"`
|
||||||
Mining bool `json:"mining"`
|
NoSale string `json:"noSale"`
|
||||||
Resale float64 `json:"resale"`
|
NoSaleText string `json:"noSaleText"`
|
||||||
NoSale string `json:"noSale"`
|
Confirm APIConfirm `json:"confirm"` // before sell-tool
|
||||||
NoSaleText string `json:"noSaleText"`
|
IdleSame int `json:"idleSame"` // while it mines: idle tools of its type
|
||||||
Confirm APIConfirm `json:"confirm"` // before sell-tool
|
IdleBest string `json:"idleBest"` // the one pickTool would deploy
|
||||||
IdleSame int `json:"idleSame"` // while it mines: idle tools of its type
|
DeployTo *APISuggestion `json:"deployTo"` // its best well
|
||||||
IdleBest string `json:"idleBest"` // the one pickTool would deploy
|
|
||||||
DeployTo *APISuggestion `json:"deployTo"` // its best well
|
|
||||||
Ladder []APITierRow `json:"ladder"`
|
|
||||||
Gear []APIGearDetail `json:"gear"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type APISlot struct {
|
func apiParts(rows []PartRow) []APIPart {
|
||||||
N int `json:"n"`
|
out := []APIPart{}
|
||||||
Attachment string `json:"attachment"` // "" empty; unequip with item=<name>&to=<toolId>
|
for _, p := range rows {
|
||||||
|
out = append(out, APIPart{Name: p.Name, Need: p.Need, Have: p.Have})
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
type APISpare struct {
|
func apiTracks(s *State, rows []TrackRow) []APITrack {
|
||||||
Name string `json:"name"`
|
out := []APITrack{}
|
||||||
Count int `json:"count"`
|
|
||||||
CycleSecs int `json:"cycleSecs"` // the drill's stats with it equipped
|
|
||||||
Yield float64 `json:"yield"`
|
|
||||||
CanEquip bool `json:"canEquip"`
|
|
||||||
Why string `json:"why"` // fitted, full
|
|
||||||
WhyText string `json:"whyText"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
Resale float64 `json:"resale"`
|
|
||||||
Confirm APIConfirm `json:"confirm"` // before sell-attachment
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiTierRows(rows []TierRow) []APITierRow {
|
|
||||||
out := []APITierRow{}
|
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
out = append(out, APITierRow{Tier: r.Tier, CycleSecs: r.Secs, Yield: r.Yield, Reach: r.Reach, Rate: r.Rate,
|
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value}
|
||||||
Current: r.Current, Next: r.Next})
|
if n := r.Next; n != nil {
|
||||||
}
|
next := &APINextLevel{Level: r.Level + 1, Value: r.NextValue, Label: r.Label(), Price: n.Price,
|
||||||
return out
|
Parts: apiParts(r.Parts), CanAfford: r.CanAfford, Short: max(0, n.Price-s.Balance),
|
||||||
}
|
BlockedText: r.Missing, ShortText: r.Short, RateFrom: r.RateFrom, RateTo: r.RateTo, Cost: r.Cost}
|
||||||
|
for _, part := range r.Parts {
|
||||||
func apiGearDetail(gear []*Attachment) []APIGearDetail {
|
if !part.OK() && next.Blocked == "" {
|
||||||
out := []APIGearDetail{}
|
|
||||||
for _, a := range gear {
|
|
||||||
out = append(out, APIGearDetail{Name: a.Name, Image: attachmentImages + a.Image, Effect: effectOf(a.Speed, a.Yield, 0, 0),
|
|
||||||
EffectText: a.Effect()})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiEquipment(p *Page) *APIEquipment {
|
|
||||||
s := p.S
|
|
||||||
e := &APIEquipment{Tools: []APIToolRow{}}
|
|
||||||
for _, row := range p.Equipment {
|
|
||||||
b := s.toolBonus(row.ID)
|
|
||||||
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
|
|
||||||
Image: itemImages + row.Item.ImageAt(row.Tier), Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle,
|
|
||||||
Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield, Attachments: apiGear(row.Gear),
|
|
||||||
Slots: len(row.Gear) + row.FreeSlots, FreeSlots: row.FreeSlots, Selected: row.Selected,
|
|
||||||
Ladder: apiTierRows(row.Ladder), Resale: row.Item.ToolResale(row.Tier), NoSale: s.noSale(row),
|
|
||||||
NoSaleText: noSaleText[s.noSale(row)],
|
|
||||||
Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}, Gear: apiGearDetail(row.Gear)}
|
|
||||||
if row.Stake != nil {
|
|
||||||
st := apiStake(*row.Stake, false)
|
|
||||||
t.Stake = &st
|
|
||||||
}
|
|
||||||
if n := row.Next; n != nil {
|
|
||||||
next := &APINextTier{Tier: n.Tier, Price: n.Price, Parts: []APIPart{}, CanAfford: row.CanAfford,
|
|
||||||
Short: max(0, n.Price-s.Balance), BlockedText: row.Missing, ShortText: row.Short}
|
|
||||||
for _, part := range row.Parts {
|
|
||||||
next.Parts = append(next.Parts, APIPart{Name: part.Name, Need: part.Need, Have: part.Have})
|
|
||||||
if !part.OK() && next.Blocked == "" && next.Short == 0 {
|
|
||||||
next.Blocked = "parts"
|
next.Blocked = "parts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if next.Short > 0 {
|
if next.Short > 0 {
|
||||||
next.Blocked = "credits"
|
next.Blocked = "credits"
|
||||||
}
|
}
|
||||||
|
if gain := r.RateGain(); gain > 0 {
|
||||||
|
secs := r.Cost / gain * 3600
|
||||||
|
next.PaybackSecs = &secs
|
||||||
|
}
|
||||||
t.Next = next
|
t.Next = next
|
||||||
}
|
}
|
||||||
if u := row.Preview; u != nil {
|
out = append(out, t)
|
||||||
pv := &APIPreview{Tier: u.Tier, YieldFrom: u.YieldFrom, YieldTo: u.YieldTo, SecsFrom: u.SecsFrom, SecsTo: u.SecsTo,
|
}
|
||||||
ReachFrom: max(1, u.ReachFrom), ReachTo: max(1, u.ReachTo), RateFrom: u.RateFrom, RateTo: u.RateTo, Cost: u.Cost}
|
return out
|
||||||
if gain := u.RateGain(); gain > 0 {
|
}
|
||||||
secs := u.Cost / gain * 3600
|
|
||||||
pv.PaybackSecs = &secs
|
// ladderKey names a tool's ladders: "<store item>:<yield level>:<cycle level>".
|
||||||
}
|
func ladderKey(storeItemID int, lv Levels) string {
|
||||||
t.Preview = pv
|
return fmt.Sprintf("%d:%d:%d", storeItemID, lv.Yield, lv.Cycle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiLadders are the item's ladders at the levels, by kind (the tracks it has).
|
||||||
|
func apiLadders(s *State, item *StoreItem, lv Levels, tracks []TrackRow) map[string][]APILadderStep {
|
||||||
|
out := map[string][]APILadderStep{}
|
||||||
|
for _, r := range tracks {
|
||||||
|
steps := []APILadderStep{}
|
||||||
|
for _, st := range s.trackLadder(item, lv, r.Kind) {
|
||||||
|
steps = append(steps, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
|
||||||
|
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
|
||||||
|
}
|
||||||
|
out[r.Kind] = steps
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiEquipment(p *Page) *APIEquipment {
|
||||||
|
s := p.S
|
||||||
|
b := s.Bonus()
|
||||||
|
e := &APIEquipment{Tools: []APIToolRow{}, Ladders: map[string]map[string][]APILadderStep{}}
|
||||||
|
for _, row := range p.Equipment {
|
||||||
|
key := ladderKey(row.StoreItemID, row.Levels())
|
||||||
|
if _, ok := e.Ladders[key]; !ok {
|
||||||
|
e.Ladders[key] = apiLadders(s, row.Item, row.Levels(), row.Tracks)
|
||||||
|
}
|
||||||
|
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
|
||||||
|
Image: itemImages + row.Item.Image, YieldLevel: row.YieldLevel, CycleLevel: row.CycleLevel,
|
||||||
|
Count: row.Count, Idle: row.Idle, Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield,
|
||||||
|
Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), LadderKey: key, Resale: row.Item.ToolResale(row.Levels()),
|
||||||
|
NoSale: s.noSale(row), NoSaleText: noSaleText[s.noSale(row)], Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}}
|
||||||
|
if row.Stake != nil {
|
||||||
|
st := apiStake(*row.Stake, false)
|
||||||
|
t.Stake = &st
|
||||||
}
|
}
|
||||||
e.Tools = append(e.Tools, t)
|
e.Tools = append(e.Tools, t)
|
||||||
}
|
}
|
||||||
if d := p.Drill; d != nil {
|
if d := p.Drill; d != nil {
|
||||||
dp := &APIDrillPanel{ToolID: d.ID, Slots: []APISlot{}, Spares: []APISpare{}, Mining: d.Mining, Resale: d.Resale,
|
dp := &APIDrillPanel{ToolID: d.ID, Mining: d.Mining, Resale: d.Resale,
|
||||||
NoSale: s.noSale(d.ToolRow), NoSaleText: d.NoSale, IdleSame: d.IdleSame, Ladder: apiTierRows(d.Ladder),
|
NoSale: s.noSale(d.ToolRow), NoSaleText: d.NoSale, IdleSame: d.IdleSame,
|
||||||
Gear: apiGearDetail(d.Gear),
|
|
||||||
Confirm: APIConfirm{Title: "Sell drill", Text: d.SellConfirm(), OK: "Sell for " + formatCredits(d.Resale), Danger: true}}
|
Confirm: APIConfirm{Title: "Sell drill", Text: d.SellConfirm(), OK: "Sell for " + formatCredits(d.Resale), Danger: true}}
|
||||||
for _, slot := range d.Slots {
|
|
||||||
a := APISlot{N: slot.N}
|
|
||||||
if slot.Gear != nil {
|
|
||||||
a.Attachment = slot.Gear.Name
|
|
||||||
}
|
|
||||||
dp.Slots = append(dp.Slots, a)
|
|
||||||
}
|
|
||||||
for _, sg := range d.Spares {
|
|
||||||
sp := APISpare{Name: sg.Name, Count: sg.Count, CycleSecs: sg.Secs, Yield: sg.Yield, CanEquip: sg.CanEquip,
|
|
||||||
WhyText: sg.Why, Price: sg.Price, Resale: sg.Resale(),
|
|
||||||
Confirm: APIConfirm{Title: "Sell " + sg.Name, Text: sg.Confirm(), OK: "Sell for " + formatCredits(sg.Resale()), Danger: true}}
|
|
||||||
switch {
|
|
||||||
case sg.CanEquip:
|
|
||||||
case strings.HasPrefix(sg.Why, "Already"):
|
|
||||||
sp.Why = "fitted"
|
|
||||||
default:
|
|
||||||
sp.Why = "full"
|
|
||||||
}
|
|
||||||
dp.Spares = append(dp.Spares, sp)
|
|
||||||
}
|
|
||||||
if d.IdleBest != nil {
|
if d.IdleBest != nil {
|
||||||
dp.IdleBest = d.IdleBest.ID
|
dp.IdleBest = d.IdleBest.ID
|
||||||
}
|
}
|
||||||
@@ -535,7 +488,7 @@ func apiEquipment(p *Page) *APIEquipment {
|
|||||||
type APIBuyModal struct {
|
type APIBuyModal struct {
|
||||||
ToolID string `json:"toolId"`
|
ToolID string `json:"toolId"`
|
||||||
StoreItemID int `json:"storeItemId"`
|
StoreItemID int `json:"storeItemId"`
|
||||||
CycleSecs int `json:"cycleSecs"`
|
CycleSecs float64 `json:"cycleSecs"`
|
||||||
Yield float64 `json:"yield"`
|
Yield float64 `json:"yield"`
|
||||||
Reach int `json:"reach"`
|
Reach int `json:"reach"`
|
||||||
NoMoons bool `json:"noMoons"`
|
NoMoons bool `json:"noMoons"`
|
||||||
@@ -579,8 +532,7 @@ func apiBuyModal(m *BuyModal) *APIBuyModal {
|
|||||||
// ------------------------------------------------------------------ store
|
// ------------------------------------------------------------------ store
|
||||||
|
|
||||||
type APIStore struct {
|
type APIStore struct {
|
||||||
Tools []APIStoreTool `json:"tools"`
|
Tools []APIStoreTool `json:"tools"`
|
||||||
Attachments []APIStoreGear `json:"attachments"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIStoreTool struct {
|
type APIStoreTool struct {
|
||||||
@@ -593,27 +545,13 @@ type APIStoreTool struct {
|
|||||||
CanAfford bool `json:"canAfford"`
|
CanAfford bool `json:"canAfford"`
|
||||||
Owned int `json:"owned"`
|
Owned int `json:"owned"`
|
||||||
Yield float64 `json:"yield"` // base, as the card shows
|
Yield float64 `json:"yield"` // base, as the card shows
|
||||||
CycleSecs int `json:"cycleSecs"` // base
|
CycleSecs float64 `json:"cycleSecs"` // base
|
||||||
Reach int `json:"reach"`
|
Reach int `json:"reach"`
|
||||||
Rate float64 `json:"rate"` // credits per hour it would earn, with advisors
|
Rate float64 `json:"rate"` // credits per hour it would earn, with advisors
|
||||||
BestRate float64 `json:"bestRate"` // the same for the player's best tool
|
BestRate float64 `json:"bestRate"` // the same for the player's best tool
|
||||||
WhyText string `json:"whyText"` // why Buy is disabled ("Need ₥X more"), "" if it isn't
|
WhyText string `json:"whyText"` // why Buy is disabled ("Need ₥X more"), "" if it isn't
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIStoreGear struct {
|
|
||||||
Name string `json:"name"` // buy-attachment item=<name>
|
|
||||||
Image string `json:"image"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Effect APIEffect `json:"effect"`
|
|
||||||
EffectText string `json:"effectText"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
CanAfford bool `json:"canAfford"`
|
|
||||||
Spare int `json:"spare"`
|
|
||||||
Equipped int `json:"equipped"`
|
|
||||||
Resale float64 `json:"resale"`
|
|
||||||
WhyText string `json:"whyText"` // why Buy is disabled, "" if it isn't
|
|
||||||
}
|
|
||||||
|
|
||||||
// whyShort is the web's tooltip on a Buy button the player can't afford, "" when they can.
|
// whyShort is the web's tooltip on a Buy button the player can't afford, "" when they can.
|
||||||
func whyShort(canAfford bool, balance, price float64) string {
|
func whyShort(canAfford bool, balance, price float64) string {
|
||||||
if canAfford {
|
if canAfford {
|
||||||
@@ -624,17 +562,12 @@ func whyShort(canAfford bool, balance, price float64) string {
|
|||||||
|
|
||||||
func apiStore(p *Page) *APIStore {
|
func apiStore(p *Page) *APIStore {
|
||||||
bal := p.S.Balance
|
bal := p.S.Balance
|
||||||
st := &APIStore{Tools: []APIStoreTool{}, Attachments: []APIStoreGear{}}
|
st := &APIStore{Tools: []APIStoreTool{}}
|
||||||
for _, r := range p.Store {
|
for _, r := range p.Store {
|
||||||
st.Tools = append(st.Tools, APIStoreTool{StoreItemID: r.Item.ID, Name: r.Item.Name, Code: toolCode(r.Item.Name),
|
st.Tools = append(st.Tools, APIStoreTool{StoreItemID: r.Item.ID, Name: r.Item.Name, Code: toolCode(r.Item.Name),
|
||||||
Image: itemImages + r.Item.Image, Description: r.Item.Description, Price: r.Item.Price, CanAfford: r.CanAfford,
|
Image: itemImages + r.Item.Image, Description: r.Item.Description, Price: r.Item.Price, CanAfford: r.CanAfford,
|
||||||
Owned: r.Owned, Yield: r.Item.ClaimAmount, CycleSecs: r.Item.CompletionTimeInSecs,
|
Owned: r.Owned, Yield: r.Item.ClaimAmount, CycleSecs: float64(r.Item.CompletionTimeInSecs),
|
||||||
Reach: max(1, r.Item.ToolReach(0)), Rate: r.Rate, BestRate: r.BestRate, WhyText: whyShort(r.CanAfford, bal, r.Item.Price)})
|
Reach: r.Item.ToolReach(), Rate: r.Rate, BestRate: r.BestRate, WhyText: whyShort(r.CanAfford, bal, r.Item.Price)})
|
||||||
}
|
|
||||||
for _, g := range p.Gear {
|
|
||||||
st.Attachments = append(st.Attachments, APIStoreGear{Name: g.Name, Image: attachmentImages + g.Image,
|
|
||||||
Description: g.Description, Effect: effectOf(g.Speed, g.Yield, 0, 0), EffectText: g.Attachment.Effect(), Price: g.Price,
|
|
||||||
CanAfford: g.CanAfford, Spare: g.Spare, Equipped: g.Equipped, Resale: g.Resale(), WhyText: whyShort(g.CanAfford, bal, g.Price)})
|
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
@@ -911,7 +844,7 @@ func apiHelp(s *State) *APIHelp {
|
|||||||
"Pick a well on your moon and deploy a tool on it.",
|
"Pick a well on your moon and deploy a tool on it.",
|
||||||
`When the drill finishes, claim what it dug up. "Claim and redeploy" puts the tool straight back to work; after a claim, "Redrill" does the same.`,
|
`When the drill finishes, claim what it dug up. "Claim and redeploy" puts the tool straight back to work; after a claim, "Redrill" does the same.`,
|
||||||
"Sell resources in the Market, upgrade your equipment, claim more moons.",
|
"Sell resources in the Market, upgrade your equipment, claim more moons.",
|
||||||
"Buy attachments in the Store and equip them on a drill (click it in Equipment) to make it faster or bigger. Hire advisors to boost your whole operation.",
|
"Each drill has two upgrade tracks in Equipment: one raises what a cycle mines, the other makes cycles shorter. Hire advisors to boost your whole operation.",
|
||||||
}, Note: "Your progress is saved on this device.",
|
}, Note: "Your progress is saved on this device.",
|
||||||
NewGame: APIConfirm{Title: "Start over", Text: newGameConfirm, OK: "Start over", Danger: true}}
|
NewGame: APIConfirm{Title: "Start over", Text: newGameConfirm, OK: "Start over", Danger: true}}
|
||||||
}
|
}
|
||||||
@@ -941,13 +874,27 @@ type APIMoonGrade struct {
|
|||||||
SupplyMax float64 `json:"supplyMax"`
|
SupplyMax float64 `json:"supplyMax"`
|
||||||
Default bool `json:"default"` // what buy-moon without a grade buys
|
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
|
WhyText string `json:"whyText"` // why its buy button is disabled, "" if it isn't
|
||||||
|
// Moon packs (once the store is open): buy-moon with grade=<grade>&count=<count>.
|
||||||
|
Packs []APIMoonPack `json:"packs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIMoonPack is count moons of a grade bought at once, for price (discount off; each moon
|
||||||
|
// at least a little better than a single one's floor).
|
||||||
|
type APIMoonPack struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Discount float64 `json:"discount"`
|
||||||
|
CanAfford bool `json:"canAfford"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiMoonGrades(gs []GradeOffer) []APIMoonGrade {
|
func apiMoonGrades(gs []GradeOffer) []APIMoonGrade {
|
||||||
out := []APIMoonGrade{}
|
out := []APIMoonGrade{}
|
||||||
for _, g := range gs {
|
for _, g := range gs {
|
||||||
a := APIMoonGrade{Grade: g.Grade, Price: g.Price, CanAfford: g.CanAfford, WellsMin: g.WellsMin, WellsMax: g.WellsMax,
|
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}
|
SupplyMin: g.SupplyMin, SupplyMax: g.SupplyMax, Default: g.Default, Packs: []APIMoonPack{}}
|
||||||
|
for _, p := range g.Packs {
|
||||||
|
a.Packs = append(a.Packs, APIMoonPack{Count: p.Count, Price: p.Price, Discount: p.Discount, CanAfford: p.CanAfford})
|
||||||
|
}
|
||||||
if !g.CanAfford {
|
if !g.CanAfford {
|
||||||
a.WhyText = "Not enough credits"
|
a.WhyText = "Not enough credits"
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-13
@@ -102,7 +102,7 @@ func TestAPI(t *testing.T) {
|
|||||||
t.Errorf("view %+v: %d stakes, %d tools", v, len(a.Stakes), len(a.Tools))
|
t.Errorf("view %+v: %d stakes, %d tools", v, len(a.Stakes), len(a.Tools))
|
||||||
}
|
}
|
||||||
for _, st := range a.Stakes {
|
for _, st := range a.Stakes {
|
||||||
if st.End-st.Start != int64(st.Secs)*1000 {
|
if st.End-st.Start != int64(math.Round(st.Secs*1000)) {
|
||||||
t.Errorf("stake %s: end - start is not its duration", st.ID)
|
t.Errorf("stake %s: end - start is not its duration", st.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,27 +191,28 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
|
|||||||
if len(c.Reach) != len(c.Resources) {
|
if len(c.Reach) != len(c.Resources) {
|
||||||
fail("reach texts", c.Reach)
|
fail("reach texts", c.Reach)
|
||||||
}
|
}
|
||||||
for _, at := range c.Attachments {
|
for _, tool := range c.Tools {
|
||||||
if at.EffectText == "" {
|
if len(tool.Tracks) != 2 || tool.Tracks[0].Kind != "yield" || tool.Tracks[1].Kind != "cycle" {
|
||||||
fail("attachment effect", at)
|
fail("tool tracks", tool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if e := a.Equipment; e != nil {
|
if e := a.Equipment; e != nil {
|
||||||
for _, r := range e.Tools {
|
for _, r := range e.Tools {
|
||||||
if (r.NoSale != "") != (r.NoSaleText != "") || r.Next != nil && (r.Next.Blocked != "") != (r.Next.BlockedText != "") {
|
if (r.NoSale != "") != (r.NoSaleText != "") {
|
||||||
fail("tool row texts", r)
|
fail("tool row texts", r)
|
||||||
}
|
}
|
||||||
|
for _, tr := range r.Tracks {
|
||||||
|
if n := tr.Next; n != nil && ((n.Blocked != "") != (n.BlockedText != "") || n.Label == "") ||
|
||||||
|
len(e.Ladders[r.LadderKey][tr.Kind]) != tr.Max+1 {
|
||||||
|
fail("track texts", tr)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if d := e.Drill; d != nil {
|
if d := e.Drill; d != nil {
|
||||||
if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" {
|
if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" {
|
||||||
fail("drill panel texts", d)
|
fail("drill panel texts", d)
|
||||||
}
|
}
|
||||||
for _, sp := range d.Spares {
|
|
||||||
if (sp.Why != "") != (sp.WhyText != "") || sp.Confirm.Text == "" {
|
|
||||||
fail("spare texts", sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if st := a.Store; st != nil {
|
if st := a.Store; st != nil {
|
||||||
@@ -268,11 +269,9 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
|
|||||||
t.Errorf("view %+v: equipment %+v", v, a.Equipment)
|
t.Errorf("view %+v: equipment %+v", v, a.Equipment)
|
||||||
} else if (a.Equipment.Drill != nil) != (v.Drill != "") {
|
} else if (a.Equipment.Drill != nil) != (v.Drill != "") {
|
||||||
t.Errorf("view %+v: drill panel %v", v, a.Equipment.Drill != nil)
|
t.Errorf("view %+v: drill panel %v", v, a.Equipment.Drill != nil)
|
||||||
} else if d := a.Equipment.Drill; d != nil && len(d.Slots) != cfg().Attachments.SlotsPerTool {
|
|
||||||
t.Errorf("view %+v: %d slots", v, len(d.Slots))
|
|
||||||
}
|
}
|
||||||
case "store":
|
case "store":
|
||||||
if a.Store == nil || len(a.Store.Tools) != len(s.Config.StoreItems) || len(a.Store.Attachments) != len(cfg().Attachments.Items) {
|
if a.Store == nil || len(a.Store.Tools) != len(s.Config.StoreItems) {
|
||||||
t.Errorf("view %+v: store %+v", v, a.Store)
|
t.Errorf("view %+v: store %+v", v, a.Store)
|
||||||
}
|
}
|
||||||
case "refinery":
|
case "refinery":
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# Drill attachment art
|
|
||||||
|
|
||||||
The attachment images (`web/static/assets/attachments/<image>`, `image` in
|
|
||||||
`config/game.json` `attachments.items`) are rendered in Blender (4.5 LTS, Cycles on
|
|
||||||
CPU) with the equipment pipeline: `art/equipment/lib.py` for materials, the regolith
|
|
||||||
ground, world, lights, camera and render settings.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
art/attachments/render_all.sh # all four
|
|
||||||
art/attachments/render_all.sh diamond-bit # just one
|
|
||||||
```
|
|
||||||
|
|
||||||
`render.py` builds and renders at 1024px, `finish.py` (system `python3` with Pillow)
|
|
||||||
writes the 512px PNGs and contact sheets in `build/`. `--res N` and `--samples N`
|
|
||||||
give quick previews.
|
|
||||||
|
|
||||||
`parts.py`: one build function per attachment and `PARTS` (key = image name, detail
|
|
||||||
scale `S`, ground seed).
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""Turn raw 1024px renders into the 512px game images, plus contact sheets.
|
|
||||||
|
|
||||||
python3 art/attachments/finish.py [RAW_DIR] [--sheet DIR]
|
|
||||||
|
|
||||||
Needs Pillow. Writes web/static/assets/attachments/<name>.png for every <name>.png in RAW_DIR
|
|
||||||
(the names are the `image` slugs in config/game.json).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
OUT = os.path.normpath(os.path.join(HERE, '..', '..', 'web', 'static', 'assets', 'attachments'))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
a = sys.argv[1:]
|
|
||||||
sheet_dir = None
|
|
||||||
if '--sheet' in a:
|
|
||||||
i = a.index('--sheet')
|
|
||||||
sheet_dir = a[i + 1]
|
|
||||||
del a[i:i + 2]
|
|
||||||
raw = a[0] if a else os.path.join(HERE, 'build')
|
|
||||||
os.makedirs(OUT, exist_ok=True)
|
|
||||||
imgs = []
|
|
||||||
for fname in sorted(f for f in os.listdir(raw) if f.endswith('.png') and not f.startswith('contact_sheet')):
|
|
||||||
im = Image.open(os.path.join(raw, fname)).convert('RGB').resize((512, 512), Image.LANCZOS)
|
|
||||||
out = os.path.join(OUT, fname)
|
|
||||||
im.save(out, 'PNG', optimize=True)
|
|
||||||
if os.path.getsize(out) > 200_000:
|
|
||||||
q = im.quantize(256, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.FLOYDSTEINBERG)
|
|
||||||
q.save(out, 'PNG', optimize=True)
|
|
||||||
print(f'{fname}: {os.path.getsize(out) // 1024} KB')
|
|
||||||
imgs.append(Image.open(out).convert('RGB'))
|
|
||||||
if sheet_dir and imgs:
|
|
||||||
os.makedirs(sheet_dir, exist_ok=True)
|
|
||||||
for size, fn in ((512, 'contact_sheet.png'), (200, 'contact_sheet_200px.png')):
|
|
||||||
pad = 8
|
|
||||||
sheet = Image.new('RGB', (len(imgs) * (size + pad) + pad, size + 2 * pad), (4, 10, 22))
|
|
||||||
for i, im in enumerate(imgs):
|
|
||||||
sheet.paste(im.resize((size, size), Image.LANCZOS), (pad + i * (size + pad), pad))
|
|
||||||
sheet.save(os.path.join(sheet_dir, fn))
|
|
||||||
print('contact sheets in', sheet_dir)
|
|
||||||
|
|
||||||
|
|
||||||
main()
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""The four drill attachments. Each build function draws into a lib.Builder (art/equipment).
|
|
||||||
|
|
||||||
Coordinates: metres, Z up, front faces -Y (the camera sits front-right, +X/-Y).
|
|
||||||
Keys are the image names (`image` in config/game.json `attachments.items`, minus .png).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
|
|
||||||
from lib import CYAN, AMBER
|
|
||||||
|
|
||||||
PI = math.pi
|
|
||||||
|
|
||||||
|
|
||||||
# Turbo Core: an overdriven motor core. Finned housing, glowing copper windings, flange.
|
|
||||||
def turbo_core(b):
|
|
||||||
z0 = 0.05
|
|
||||||
# mounting flange and feet
|
|
||||||
b.cyl(0.17, 0.035, 'graphite', (0, 0, z0 + 0.0175))
|
|
||||||
for k in range(8):
|
|
||||||
a = k / 8 * 2 * PI + PI / 8
|
|
||||||
b.cyl(0.011, 0.012, 'chrome', (math.cos(a) * 0.145, math.sin(a) * 0.145, z0 + 0.04), seg=8)
|
|
||||||
for sx in (-1, 1):
|
|
||||||
b.box((0.06, 0.32, 0.05), 'dark', (sx * 0.14, 0, 0.025), bevel=0.008)
|
|
||||||
# housing: lower stator drum with radial fins
|
|
||||||
zc = z0 + 0.035
|
|
||||||
b.cyl(0.12, 0.22, 'steel', (0, 0, zc + 0.11))
|
|
||||||
for k in range(20):
|
|
||||||
a = k / 20 * 2 * PI
|
|
||||||
with b.at((0, 0, zc + 0.11), (0, 0, a)):
|
|
||||||
b.box((0.05, 0.008, 0.2), 'graphite', (0.135, 0, 0), bevel=0.002)
|
|
||||||
for z in (zc + 0.015, zc + 0.205):
|
|
||||||
b.torus(0.155, 0.012, 'graphite', (0, 0, z))
|
|
||||||
# exposed windings: glowing core behind a cage
|
|
||||||
zw = zc + 0.22
|
|
||||||
b.cyl(0.085, 0.13, 'core_hot', (0, 0, zw + 0.065))
|
|
||||||
b.helix((0, 0), 0.092, zw + 0.008, zw + 0.122, 7, 0.008, 'brass')
|
|
||||||
for k in range(8):
|
|
||||||
a = k / 8 * 2 * PI
|
|
||||||
b.strut((math.cos(a) * 0.105, math.sin(a) * 0.105, zw), (math.cos(a) * 0.105, math.sin(a) * 0.105, zw + 0.13),
|
|
||||||
0.006, 'chrome', seg=8)
|
|
||||||
b.light((0, -0.16, zw + 0.07), AMBER, 6, 0.03)
|
|
||||||
b.light((0.16, 0.0, zw + 0.07), AMBER, 3, 0.03)
|
|
||||||
# cap with turbine fan and shaft coupling
|
|
||||||
zt = zw + 0.13
|
|
||||||
b.cyl(0.115, 0.03, 'white', (0, 0, zt + 0.015))
|
|
||||||
b.cyl(0.08, 0.02, 'dark', (0, 0, zt + 0.04))
|
|
||||||
for k in range(12):
|
|
||||||
a = k / 12 * 2 * PI
|
|
||||||
with b.at((0, 0, zt + 0.045), (0, 0, a)):
|
|
||||||
b.box((0.065, 0.004, 0.012), 'metal', (0.04, 0, 0), (0.6, 0, 0), bevel=0.001)
|
|
||||||
b.cyl(0.03, 0.08, 'chrome', (0, 0, zt + 0.08))
|
|
||||||
b.cyl(0.04, 0.02, 'graphite', (0, 0, zt + 0.125))
|
|
||||||
for k in range(4):
|
|
||||||
a = k / 4 * 2 * PI
|
|
||||||
b.box((0.012, 0.012, 0.02), 'chrome', (math.cos(a) * 0.04, math.sin(a) * 0.04, zt + 0.14), (0, 0, a), bevel=0.002)
|
|
||||||
# status lamp, label plate and cables
|
|
||||||
b.box((0.08, 0.006, 0.04), 'white', (0.0, -0.157, zc + 0.12), bevel=0.002)
|
|
||||||
b.box((0.05, 0.004, 0.006), 'amber', (0.0, -0.161, zc + 0.12), bevel=0)
|
|
||||||
b.lamp((0.1, -0.1, zt + 0.035), 0.012, 'red', housing=False)
|
|
||||||
b.tube([(-0.12, 0.08, zw + 0.03), (-0.2, 0.12, zw - 0.05), (-0.24, 0.05, 0.02)], 0.012, 'rubber')
|
|
||||||
b.tube([(-0.12, 0.03, zw + 0.06), (-0.22, 0.02, zw - 0.02), (-0.26, -0.06, 0.02)], 0.009, 'orange')
|
|
||||||
|
|
||||||
|
|
||||||
# Diamond Bit: a fluted drill bit with a diamond-studded cutting crown, upright on its shank.
|
|
||||||
def diamond_bit(b):
|
|
||||||
# shank in a coupling block on the ground
|
|
||||||
b.box((0.2, 0.2, 0.05), 'graphite', (0, 0, 0.025), bevel=0.01)
|
|
||||||
b.hazard_band(0.21, 0.21, 0.015, (0, 0, 0.055))
|
|
||||||
b.cyl(0.065, 0.08, 'steel', (0, 0, 0.1))
|
|
||||||
for k in range(6):
|
|
||||||
a = k / 6 * 2 * PI
|
|
||||||
b.box((0.018, 0.035, 0.07), 'metal', (math.cos(a) * 0.066, math.sin(a) * 0.066, 0.1), (0, 0, a), bevel=0.004)
|
|
||||||
# fluted body
|
|
||||||
z0, z1 = 0.14, 0.44
|
|
||||||
b.cyl(0.045, z1 - z0, 'graphite', (0, 0, (z0 + z1) / 2))
|
|
||||||
for k in range(2):
|
|
||||||
pts = []
|
|
||||||
n = 48
|
|
||||||
for i in range(n + 1):
|
|
||||||
t = i / n
|
|
||||||
a = k * PI + t * 2.6 * PI
|
|
||||||
pts.append((math.cos(a) * 0.055, math.sin(a) * 0.055, z0 + (z1 - z0) * t))
|
|
||||||
b.tube(pts, 0.024, 'metal', seg=12, steps=2)
|
|
||||||
# crown: carbide ring with diamond studs, cone tip
|
|
||||||
zc = z1 + 0.01
|
|
||||||
b.lathe([(0.07, zc - 0.03), (0.09, zc), (0.09, zc + 0.035), (0.08, zc + 0.045), (0.0, zc + 0.045)], 'graphite')
|
|
||||||
b.lathe([(0.078, zc + 0.045), (0.05, zc + 0.09), (0.015, zc + 0.13), (0.0, zc + 0.135)], 'steel')
|
|
||||||
for k in range(12):
|
|
||||||
a = k / 12 * 2 * PI
|
|
||||||
b.sphere(0.014, 'diamond', (math.cos(a) * 0.089, math.sin(a) * 0.089, zc + 0.02), seg=6)
|
|
||||||
for k in range(7):
|
|
||||||
a = k / 7 * 2 * PI + 0.3
|
|
||||||
b.sphere(0.011, 'diamond', (math.cos(a) * 0.05, math.sin(a) * 0.05, zc + 0.088), seg=6)
|
|
||||||
b.sphere(0.01, 'diamond', (0, 0, zc + 0.136), seg=6)
|
|
||||||
b.light((0.0, -0.15, zc + 0.06), CYAN, 3, 0.02)
|
|
||||||
# a couple of loose diamonds on the ground
|
|
||||||
for x, y in ((0.16, -0.1), (-0.13, -0.15), (0.07, -0.17)):
|
|
||||||
b.sphere(0.013, 'diamond', (x, y, 0.008), seg=6)
|
|
||||||
|
|
||||||
|
|
||||||
# Cryo Cooler: a coolant jacket that wraps the spindle. Frosted canister, glowing coolant
|
|
||||||
# windows, coiled lines and a gauge.
|
|
||||||
def cryo_cooler(b):
|
|
||||||
b.cyl(0.16, 0.03, 'graphite', (0, 0, 0.015))
|
|
||||||
b.cyl(0.15, 0.015, 'cyan_soft', (0, 0, 0.035))
|
|
||||||
# jacket
|
|
||||||
z0, z1 = 0.045, 0.34
|
|
||||||
b.cyl(0.11, z1 - z0, 'frost', (0, 0, (z0 + z1) / 2))
|
|
||||||
for z in (z0 + 0.02, (z0 + z1) / 2, z1 - 0.02):
|
|
||||||
b.torus(0.112, 0.012, 'steel', (0, 0, z))
|
|
||||||
for k in range(4):
|
|
||||||
a = k / 4 * 2 * PI + PI / 4
|
|
||||||
with b.at((0, 0, 0), (0, 0, a)):
|
|
||||||
b.box((0.012, 0.04, 0.1), 'coolant', (0.0, -0.104, z0 + 0.075), bevel=0.003)
|
|
||||||
b.box((0.012, 0.04, 0.1), 'coolant', (0.0, -0.104, z1 - 0.075), bevel=0.003)
|
|
||||||
b.light((0, -0.25, 0.19), CYAN, 8, 0.05)
|
|
||||||
# spindle through the middle
|
|
||||||
b.cyl(0.035, 0.12, 'chrome', (0, 0, z1 + 0.06))
|
|
||||||
b.cyl(0.075, 0.03, 'white', (0, 0, z1 + 0.015))
|
|
||||||
b.cyl(0.045, 0.02, 'graphite', (0, 0, z1 + 0.12))
|
|
||||||
# coolant coil around the jacket and feed lines
|
|
||||||
b.helix((0, 0), 0.128, z0 + 0.04, z1 - 0.04, 4, 0.007, 'chrome')
|
|
||||||
b.tube([(0.12, 0.05, z1 - 0.04), (0.2, 0.08, z1 + 0.02), (0.24, 0.12, 0.1), (0.26, 0.2, 0.02)], 0.014, 'white')
|
|
||||||
b.tube([(-0.12, 0.05, z0 + 0.05), (-0.2, 0.1, 0.05), (-0.22, 0.2, 0.02)], 0.012, 'rubber')
|
|
||||||
# pressure gauge
|
|
||||||
with b.at((-0.08, -0.1, z1 - 0.05), (0, 0, -0.6)):
|
|
||||||
b.cyl(0.03, 0.02, 'steel', (0, -0.02, 0), axis='y')
|
|
||||||
b.cyl(0.024, 0.005, 'white', (0, -0.031, 0), axis='y')
|
|
||||||
b.box((0.003, 0.002, 0.02), 'red', (0.005, -0.034, 0.004), (0, 0.6, 0), bevel=0)
|
|
||||||
# frost chips on the ground
|
|
||||||
for x, y, r in ((0.18, -0.06, 0.016), (-0.17, -0.1, 0.012), (0.08, -0.18, 0.01)):
|
|
||||||
b.sphere(r, 'ice', (x, y, r * 0.4), seg=10, scale=(1.2, 1.0, 0.6))
|
|
||||||
|
|
||||||
|
|
||||||
# Seam Scanner: ground-penetrating radar on a sled. Antenna panel facing the ground,
|
|
||||||
# rotating dish, a scan display.
|
|
||||||
def seam_scanner(b):
|
|
||||||
# sled runners
|
|
||||||
for sx in (-1, 1):
|
|
||||||
b.box((0.03, 0.42, 0.025), 'dark', (sx * 0.15, 0, 0.0125), bevel=0.008)
|
|
||||||
b.strut((sx * 0.15, -0.21, 0.012), (sx * 0.15, -0.25, 0.05), 0.013, 'dark')
|
|
||||||
# radar body with glowing underside emitter
|
|
||||||
b.box((0.36, 0.34, 0.08), 'white', (0, 0, 0.075), bevel=0.02)
|
|
||||||
b.box((0.3, 0.28, 0.02), 'cyan', (0, 0, 0.032), bevel=0.004)
|
|
||||||
b.light((0, 0, 0.01), CYAN, 5, 0.1)
|
|
||||||
b.hazard_band(0.37, 0.02, 0.02, (0, -0.17, 0.085))
|
|
||||||
# electronics box
|
|
||||||
b.box((0.22, 0.2, 0.1), 'graphite', (0.02, 0.04, 0.165), bevel=0.012)
|
|
||||||
b.vent(0.12, 0.05, (0.131, 0.06, 0.165), (0, 0, PI / 2))
|
|
||||||
# display with a scan grid
|
|
||||||
with b.at((-0.02, -0.08, 0.23), (-0.5, 0, 0)):
|
|
||||||
b.box((0.16, 0.012, 0.11), 'dark', (0, 0, 0), bevel=0.006)
|
|
||||||
b.box((0.14, 0.004, 0.09), 'glass', (0, -0.007, 0), bevel=0)
|
|
||||||
# mast with a dish
|
|
||||||
b.cyl(0.018, 0.16, 'steel', (0.08, 0.1, 0.29))
|
|
||||||
b.cyl(0.03, 0.02, 'graphite', (0.08, 0.1, 0.37))
|
|
||||||
b.lathe([(0.0, 0.0), (0.05, 0.012), (0.09, 0.04)], 'white', (0.08, 0.1, 0.39), (0.5, 0.0, -0.4))
|
|
||||||
b.strut((0.08, 0.1, 0.39), (0.08 - 0.02, 0.1 - 0.035, 0.45), 0.004, 'graphite')
|
|
||||||
b.lamp((0.06, 0.065, 0.455), 0.01, 'cyan', housing=False)
|
|
||||||
# antenna whips and lamp
|
|
||||||
b.strut((-0.13, 0.12, 0.215), (-0.15, 0.14, 0.42), 0.004, 'graphite')
|
|
||||||
b.lamp((-0.15, 0.14, 0.425), 0.009, 'amber', housing=False)
|
|
||||||
b.lamp((0.14, -0.1, 0.13), 0.01, 'amber')
|
|
||||||
# cable reel at the back
|
|
||||||
b.cyl(0.05, 0.05, 'orange', (-0.1, 0.13, 0.17), axis='x')
|
|
||||||
b.cyl(0.035, 0.055, 'rubber', (-0.1, 0.13, 0.17), axis='x')
|
|
||||||
|
|
||||||
|
|
||||||
PARTS = {
|
|
||||||
'turbo-core': {'build': turbo_core, 'S': 0.3, 'seed': 11},
|
|
||||||
'diamond-bit': {'build': diamond_bit, 'S': 0.25, 'seed': 12, 'fill': 0.8},
|
|
||||||
'cryo-cooler': {'build': cryo_cooler, 'S': 0.3, 'seed': 13},
|
|
||||||
'seam-scanner': {'build': seam_scanner, 'S': 0.3, 'seed': 14},
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""Build and render the drill attachment icons.
|
|
||||||
|
|
||||||
blender -b --factory-startup --python art/attachments/render.py -- [names...|all] [--out DIR]
|
|
||||||
[--res N] [--samples N]
|
|
||||||
|
|
||||||
Raw renders go to --out (default art/attachments/build); finish.py turns them into the
|
|
||||||
game images. Uses art/equipment/lib.py, so the parts match the store equipment.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
import bpy
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
sys.path.insert(0, HERE)
|
|
||||||
sys.path.insert(0, os.path.join(HERE, '..', 'equipment'))
|
|
||||||
|
|
||||||
import lib # noqa: E402
|
|
||||||
import parts # noqa: E402
|
|
||||||
from lib import srgb # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def args():
|
|
||||||
a = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
|
|
||||||
opts = {'out': os.path.join(HERE, 'build'), 'res': 1024, 'samples': 96}
|
|
||||||
names = []
|
|
||||||
i = 0
|
|
||||||
while i < len(a):
|
|
||||||
k = a[i]
|
|
||||||
if k == '--out':
|
|
||||||
opts['out'] = a[i + 1]; i += 2
|
|
||||||
elif k in ('--res', '--samples'):
|
|
||||||
opts[k[2:]] = int(a[i + 1]); i += 2
|
|
||||||
else:
|
|
||||||
names.append(k); i += 1
|
|
||||||
if not names or names == ['all']:
|
|
||||||
names = list(parts.PARTS)
|
|
||||||
return names, opts
|
|
||||||
|
|
||||||
|
|
||||||
def materials(S):
|
|
||||||
M = lib.make_materials(S)
|
|
||||||
M['core_hot'] = lib.emissive('core_hot', srgb('#ff8a2b'), 9.0, base=srgb('#5a2a0a'))
|
|
||||||
M['diamond'] = lib.simple('diamond', srgb('#e8f8ff'), metal=0.3, rough=0.03, coat=1.0,
|
|
||||||
emission=srgb('#9fe8ff'), estrength=0.35)
|
|
||||||
M['frost'] = lib.paint('frost', srgb('#e4f1f8'), S, rough=0.55, panels=False, wear=0.2, dirt=0.25)
|
|
||||||
M['coolant'] = lib.emissive('coolant', srgb('#5fd4ff'), 5.0, base=srgb('#10303c'))
|
|
||||||
M['ice'] = lib.simple('ice', srgb('#cfeefc'), rough=0.15, coat=1.0, emission=srgb('#5fd4ff'), estrength=0.3)
|
|
||||||
return M
|
|
||||||
|
|
||||||
|
|
||||||
def build_scene(name):
|
|
||||||
spec = parts.PARTS[name]
|
|
||||||
lib.reset()
|
|
||||||
coll = bpy.context.scene.collection
|
|
||||||
S = spec['S']
|
|
||||||
b = lib.Builder(name, materials(S), S)
|
|
||||||
spec['build'](b)
|
|
||||||
objs = b.finish(coll)
|
|
||||||
return spec, objs, coll
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
names, o = args()
|
|
||||||
for name in names:
|
|
||||||
t0 = time.time()
|
|
||||||
spec, objs, coll = build_scene(name)
|
|
||||||
pts = lib.machine_points(objs)
|
|
||||||
lo = [min(p[i] for p in pts) for i in range(3)]
|
|
||||||
hi = [max(p[i] for p in pts) for i in range(3)]
|
|
||||||
span = max(hi[0] - lo[0], hi[1] - lo[1])
|
|
||||||
lib.build_ground(coll, spec['S'], span * spec.get('ground', 0.9), ((lo[0], lo[1]), (hi[0], hi[1])),
|
|
||||||
seed=spec.get('seed', 3))
|
|
||||||
lib.build_world()
|
|
||||||
lib.add_lights(coll)
|
|
||||||
lib.setup_camera(coll, pts, fill=spec.get('fill', 0.78), aim=spec.get('aim', (0.5, 0.5)))
|
|
||||||
lib.setup_render(o['res'], o['samples'])
|
|
||||||
os.makedirs(o['out'], exist_ok=True)
|
|
||||||
sc = bpy.context.scene
|
|
||||||
sc.render.filepath = os.path.join(o['out'], name + '.png')
|
|
||||||
bpy.ops.render.render(write_still=True)
|
|
||||||
print(f'[attachments] {name}: {time.time() - t0:.1f}s', flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
main()
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Render every attachments image (or the named ones) and write the game images.
|
|
||||||
# art/attachments/render_all.sh [name ...]
|
|
||||||
set -e
|
|
||||||
here=$(cd "$(dirname "$0")" && pwd)
|
|
||||||
blender=${BLENDER:-blender}
|
|
||||||
[ $# -eq 0 ] && set -- all
|
|
||||||
"$blender" -b --factory-startup --python "$here/render.py" -- "$@" --out "$here/build"
|
|
||||||
python3 "$here/finish.py" "$here/build" --sheet "$here/build"
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// Drill attachments: equipment bought in the Store that goes into the player's inventory,
|
|
||||||
// then is equipped on one drill in the Equipment section (the drill's side panel). An
|
|
||||||
// attachment's modifier makes the drill's stakes faster (speed) or bigger (yield). A drill
|
|
||||||
// has attachments.slotsPerTool slots and takes at most one of each kind. Equipping and
|
|
||||||
// unequipping work any time, also while the drill mines: like every bonus (bonus.go), it
|
|
||||||
// counts from the drill's next stake. Table attachment (inventory_item_id null: unequipped).
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AttachmentConfig struct {
|
|
||||||
SlotsPerTool int `json:"slotsPerTool"`
|
|
||||||
Items []Attachment `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Attachment struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Image string `json:"image"` // in web/static/assets/attachments
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
Speed float64 `json:"speed,omitempty"` // modifiers (multipliers); unset is 1
|
|
||||||
Yield float64 `json:"yield,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *GameConfig) attachment(name string) *Attachment {
|
|
||||||
for i := range c.Attachments.Items {
|
|
||||||
if c.Attachments.Items[i].Name == name {
|
|
||||||
return &c.Attachments.Items[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Effect describes the modifier, e.g. "+20% speed".
|
|
||||||
func (a *Attachment) Effect() string {
|
|
||||||
var parts []string
|
|
||||||
if a.Speed > 1 {
|
|
||||||
parts = append(parts, pctText(a.Speed)+" speed")
|
|
||||||
}
|
|
||||||
if a.Yield > 1 {
|
|
||||||
parts = append(parts, pctText(a.Yield)+" yield")
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// buyAttachment buys an attachment into the player's inventory (unequipped).
|
|
||||||
func buyAttachment(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
|
|
||||||
a := cfg().attachment(name)
|
|
||||||
if a == nil {
|
|
||||||
return gameErr("That attachment isn't for sale")
|
|
||||||
}
|
|
||||||
return inTx(ctx, db, func(tx pgx.Tx) error {
|
|
||||||
if err := debit(ctx, tx, userID, a.Price); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := tx.Exec(ctx, "insert into attachment(user_id, item) values ($1, $2)", userID, a.Name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := unlock(ctx, tx, userID, "advisors"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return logEvent(ctx, tx, userID, "buy_attachment", map[string]any{"item": a.Name, "price": a.Price})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// lockTool locks one of the player's tools.
|
|
||||||
func lockTool(ctx context.Context, tx pgx.Tx, userID, toolID string) error {
|
|
||||||
if !uuidPattern.MatchString(toolID) {
|
|
||||||
return gameErr("Tool not found")
|
|
||||||
}
|
|
||||||
var id string
|
|
||||||
err := tx.QueryRow(ctx, `
|
|
||||||
select id from inventory_item where id = $1 and user_id = $2 for update`, toolID, userID).Scan(&id)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return gameErr("Tool not found")
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// equipAttachment puts one of the player's spare attachments of a kind on a tool (an idle
|
|
||||||
// copy of it, if there is one: see pickTool) and returns the tool it went on.
|
|
||||||
func equipAttachment(ctx context.Context, db *pgxpool.Pool, userID, name, toolID string) (string, error) {
|
|
||||||
a := cfg().attachment(name)
|
|
||||||
if a == nil {
|
|
||||||
return "", gameErr("Unknown attachment")
|
|
||||||
}
|
|
||||||
err := inTx(ctx, db, func(tx pgx.Tx) error {
|
|
||||||
// The tool row first, then its attachments.
|
|
||||||
var err error
|
|
||||||
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows, err := tx.Query(ctx, `
|
|
||||||
select item from attachment where inventory_item_id = $1 for update`, toolID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fitted, err := pgx.CollectRows(rows, pgx.RowTo[string])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, f := range fitted {
|
|
||||||
if f == a.Name {
|
|
||||||
return gameErr("This drill already has a %s", a.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(fitted) >= cfg().Attachments.SlotsPerTool {
|
|
||||||
return gameErr("This drill's slots are full. Unequip one first")
|
|
||||||
}
|
|
||||||
var id string
|
|
||||||
err = tx.QueryRow(ctx, `
|
|
||||||
select id from attachment
|
|
||||||
where user_id = $1 and item = $2 and inventory_item_id is null
|
|
||||||
limit 1 for update skip locked`, userID, a.Name).Scan(&id)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return gameErr("You have no spare %s. Buy one in the store", a.Name)
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = tx.Exec(ctx, "update attachment set inventory_item_id = $2 where id = $1", id, toolID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return logEvent(ctx, tx, userID, "equip", map[string]any{"item": a.Name, "tool": toolID})
|
|
||||||
})
|
|
||||||
return toolID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// unequipAttachment takes an attachment of a kind off a tool (an idle copy of it, if there
|
|
||||||
// is one), back into the inventory, and returns the tool it came off.
|
|
||||||
func unequipAttachment(ctx context.Context, db *pgxpool.Pool, userID, name, toolID string) (string, error) {
|
|
||||||
err := inTx(ctx, db, func(tx pgx.Tx) error {
|
|
||||||
var err error
|
|
||||||
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tag, err := tx.Exec(ctx, `
|
|
||||||
update attachment set inventory_item_id = null
|
|
||||||
where inventory_item_id = $1 and item = $2`, toolID, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return gameErr("That attachment isn't on this drill")
|
|
||||||
}
|
|
||||||
return logEvent(ctx, tx, userID, "unequip", map[string]any{"item": name, "tool": toolID})
|
|
||||||
})
|
|
||||||
return toolID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ view
|
|
||||||
|
|
||||||
// gear is the attachments equipped on a tool, in config order.
|
|
||||||
func (s *State) gear(toolID string) []*Attachment {
|
|
||||||
var out []*Attachment
|
|
||||||
for i := range cfg().Attachments.Items {
|
|
||||||
a := &cfg().Attachments.Items[i]
|
|
||||||
for _, name := range s.Fitted[toolID] {
|
|
||||||
if name == a.Name {
|
|
||||||
out = append(out, a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// AttachmentCard is an attachment in the store.
|
|
||||||
type AttachmentCard struct {
|
|
||||||
*Attachment
|
|
||||||
Spare, Equipped int
|
|
||||||
CanAfford bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *State) attachmentCards() []AttachmentCard {
|
|
||||||
var cards []AttachmentCard
|
|
||||||
for i := range cfg().Attachments.Items {
|
|
||||||
a := &cfg().Attachments.Items[i]
|
|
||||||
card := AttachmentCard{Attachment: a, Spare: s.Spare[a.Name], CanAfford: s.Balance >= a.Price}
|
|
||||||
for _, names := range s.Fitted {
|
|
||||||
for _, n := range names {
|
|
||||||
if n == a.Name {
|
|
||||||
card.Equipped++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cards = append(cards, card)
|
|
||||||
}
|
|
||||||
return cards
|
|
||||||
}
|
|
||||||
|
|
||||||
// DrillPanel is the side panel for a drill selected in the Equipment section: its slots
|
|
||||||
// and the spare attachments that fit, with the stats they'd give.
|
|
||||||
type DrillPanel struct {
|
|
||||||
ToolRow
|
|
||||||
Slots []SlotGear
|
|
||||||
Spares []SpareGear
|
|
||||||
Mining bool
|
|
||||||
Resale float64 // what it sells for (resale.go)
|
|
||||||
NoSale string // why it can't be sold, if it can't
|
|
||||||
|
|
||||||
// While it mines: the idle tools of its type, the best one and its best well.
|
|
||||||
IdleSame int
|
|
||||||
IdleBest *Tool
|
|
||||||
DeployTo *Suggestion
|
|
||||||
IdleURL string // opens IdleBest's panel
|
|
||||||
|
|
||||||
// Store: the Store is unlocked, so the panel offers upgrades, attachments and selling
|
|
||||||
// (actions gated on "store", onboarding.go). Before that it shows stats and tiers only.
|
|
||||||
Store bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// SlotGear is one attachment slot; Gear is nil when it's empty.
|
|
||||||
type SlotGear struct {
|
|
||||||
N int
|
|
||||||
Gear *Attachment
|
|
||||||
}
|
|
||||||
|
|
||||||
// SpareGear is a kind of spare attachment and the drill's stats with it equipped.
|
|
||||||
type SpareGear struct {
|
|
||||||
*Attachment
|
|
||||||
Count int
|
|
||||||
Secs int
|
|
||||||
Yield float64
|
|
||||||
CanEquip bool
|
|
||||||
Why string // why it can't be equipped
|
|
||||||
}
|
|
||||||
|
|
||||||
// noSale is why a tool can't be sold (only-tool, claim-first, mining), "" if it can.
|
|
||||||
func (s *State) noSale(row ToolRow) string {
|
|
||||||
switch {
|
|
||||||
case len(s.Tools) <= 1:
|
|
||||||
return "only-tool"
|
|
||||||
case row.Stake != nil && row.Stake.Ready:
|
|
||||||
return "claim-first"
|
|
||||||
case row.Stake != nil:
|
|
||||||
return "mining"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm is the question before selling one (the Sell button's confirm dialog).
|
|
||||||
func (sg SpareGear) Confirm() string {
|
|
||||||
return fmt.Sprintf("Sell a spare %s for %s? You paid %s.", sg.Name, formatCredits(sg.Resale()), formatCredits(sg.Price))
|
|
||||||
}
|
|
||||||
|
|
||||||
// SellConfirm is the question before selling the drill (its Sell button's confirm dialog).
|
|
||||||
func (d *DrillPanel) SellConfirm() string {
|
|
||||||
q := fmt.Sprintf("Sell this %s (T%d) for %s?", d.Item.Name, d.Tier, formatCredits(d.Resale))
|
|
||||||
if len(d.Gear) > 0 {
|
|
||||||
q += " Its attachments go back to your inventory."
|
|
||||||
}
|
|
||||||
return q
|
|
||||||
}
|
|
||||||
|
|
||||||
var noSaleText = map[string]string{
|
|
||||||
"only-tool": "You can't sell your only tool",
|
|
||||||
"claim-first": "Claim its haul first",
|
|
||||||
"mining": "Still mining. Sell it once it's idle",
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *State) drillPanel(row ToolRow) *DrillPanel {
|
|
||||||
d := &DrillPanel{ToolRow: row, Mining: row.Stake != nil, Resale: row.Item.ToolResale(row.Tier),
|
|
||||||
Store: s.Unlocked("store")}
|
|
||||||
d.NoSale = noSaleText[s.noSale(row)]
|
|
||||||
for i := range cfg().Attachments.SlotsPerTool {
|
|
||||||
slot := SlotGear{N: i + 1}
|
|
||||||
if i < len(row.Gear) {
|
|
||||||
slot.Gear = row.Gear[i]
|
|
||||||
}
|
|
||||||
d.Slots = append(d.Slots, slot)
|
|
||||||
}
|
|
||||||
for i := range cfg().Attachments.Items {
|
|
||||||
a := &cfg().Attachments.Items[i]
|
|
||||||
n := s.Spare[a.Name]
|
|
||||||
if n == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sg := SpareGear{Attachment: a, Count: n, CanEquip: true}
|
|
||||||
names := append([]string{a.Name}, s.Fitted[row.ID]...)
|
|
||||||
sg.Secs, sg.Yield = drillStats(row.Item.ToolSecs(row.Tier), row.Item.ToolYield(row.Tier),
|
|
||||||
cfg().bonusesFor(s.Advisors, names), s.CycleBoosts.For("all"), s.YieldBoosts.For(""))
|
|
||||||
for _, g := range row.Gear {
|
|
||||||
if g.Name == a.Name {
|
|
||||||
sg.CanEquip, sg.Why = false, "Already on this drill"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sg.CanEquip && row.FreeSlots == 0 {
|
|
||||||
sg.CanEquip, sg.Why = false, "Slots full. Unequip one first"
|
|
||||||
}
|
|
||||||
d.Spares = append(d.Spares, sg)
|
|
||||||
}
|
|
||||||
if d.Mining {
|
|
||||||
// Highest tier, then most attachments: the one pickTool would deploy.
|
|
||||||
for i := range s.Tools {
|
|
||||||
t := &s.Tools[i]
|
|
||||||
if t.StoreItemID != row.StoreItemID || s.StakeForTool(t.ID) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
d.IdleSame++
|
|
||||||
if b := d.IdleBest; b == nil || t.Tier > b.Tier ||
|
|
||||||
(t.Tier == b.Tier && len(s.Fitted[t.ID]) > len(s.Fitted[b.ID])) {
|
|
||||||
d.IdleBest = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d.IdleBest != nil {
|
|
||||||
d.IdleURL = View{Section: "equipment", Drill: d.IdleBest.ID}.URL()
|
|
||||||
if m := s.buySuggestions(d.IdleBest.ID); m != nil && len(m.Suggestions) > 0 {
|
|
||||||
d.DeployTo = &m.Suggestions[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
+3
-3
@@ -27,9 +27,9 @@ func runSim(args []string) error {
|
|||||||
for _, t := range a.Tools {
|
for _, t := range a.Tools {
|
||||||
fmt.Printf("%-38s %8.0f %5d %6.0f %5d %7.0f %8.0f %8s %7s\n", t.Name, t.Price, t.Secs, t.Claim,
|
fmt.Printf("%-38s %8.0f %5d %6.0f %5d %7.0f %8.0f %8s %7s\n", t.Name, t.Price, t.Secs, t.Claim,
|
||||||
t.Reach, t.Eff, t.CreditsPerHour, t.Payback, t.Ratio)
|
t.Reach, t.Eff, t.CreditsPerHour, t.Payback, t.Ratio)
|
||||||
for _, u := range t.Tiers {
|
for _, u := range t.Levels {
|
||||||
fmt.Printf(" tier %d %6.0f + parts %5.0f yield %5.0f wells %d on moon %5.0f %8.0f ₥/h payback %s\n",
|
fmt.Printf(" %-5s %d %8.0f + parts %5.0f yield %5.0f cycle %4.1fs on moon %5.0f %9.0f ₥/h payback %s\n",
|
||||||
u.Tier, u.Price, u.PartsValue, u.Yield, u.Reach, u.Eff, u.CreditsPerHour, u.Payback)
|
u.Track, u.Level, u.Price, u.PartsValue, u.Yield, u.Secs, u.Eff, u.CreditsPerHour, u.Payback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println("\nrefinery")
|
fmt.Println("\nrefinery")
|
||||||
|
|||||||
+24
-72
@@ -1,8 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// Player bonuses: drill attachments (attachments.go, equipped on one tool; their bonus is
|
// Player bonuses: advisors (advisors.go, for the whole operation). Each bonus is a
|
||||||
// the modifier) and advisors (advisors.go, for the whole operation). Each bonus is a
|
// multiplier (1.1 = +10%). A player's advisors multiply, then each kind is capped by bonusCaps
|
||||||
// multiplier (1.1 = +10%). Within a player's attachments and advisors they multiply, then each kind is capped by bonusCaps
|
|
||||||
// in the config; live events (events.go) multiply on top, uncapped. Bonuses are applied
|
// in the config; live events (events.go) multiply on top, uncapped. Bonuses are applied
|
||||||
// when an action runs: a stake fixes its duration and amount when it starts.
|
// when an action runs: a stake fixes its duration and amount when it starts.
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BonusCaps are the highest combined multipliers a player's attachments and advisors give.
|
// BonusCaps are the highest combined multipliers a player's advisors give.
|
||||||
type BonusCaps struct {
|
type BonusCaps struct {
|
||||||
Speed float64 `json:"speed"` // drill speed: cycle duration is divided by it
|
Speed float64 `json:"speed"` // drill speed: cycle duration is divided by it
|
||||||
Yield float64 `json:"yield"` // resources per stake
|
Yield float64 `json:"yield"` // resources per stake
|
||||||
@@ -38,8 +37,8 @@ func mult(v float64) float64 {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// bonusesFor combines the named advisors and attachments, capped.
|
// bonusesFor combines the named advisors, capped.
|
||||||
func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
|
func (c *GameConfig) bonusesFor(advisors []string) Bonus {
|
||||||
b := noBonus
|
b := noBonus
|
||||||
for _, name := range advisors {
|
for _, name := range advisors {
|
||||||
if a := c.advisor(name); a != nil {
|
if a := c.advisor(name); a != nil {
|
||||||
@@ -49,12 +48,6 @@ func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
|
|||||||
b.Refine *= mult(a.Refine)
|
b.Refine *= mult(a.Refine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, name := range attachments {
|
|
||||||
if m := c.attachment(name); m != nil {
|
|
||||||
b.Speed *= mult(m.Speed)
|
|
||||||
b.Yield *= mult(m.Yield)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.Speed = min(b.Speed, c.BonusCaps.Speed)
|
b.Speed = min(b.Speed, c.BonusCaps.Speed)
|
||||||
b.Yield = min(b.Yield, c.BonusCaps.Yield)
|
b.Yield = min(b.Yield, c.BonusCaps.Yield)
|
||||||
b.Sell = min(b.Sell, c.BonusCaps.Sell)
|
b.Sell = min(b.Sell, c.BonusCaps.Sell)
|
||||||
@@ -62,10 +55,10 @@ func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// drillStats is a tool's cycle (seconds) and yield per stake with the player's bonus and
|
// drillStats is a tool's cycle (seconds, to a tenth, at least 1) and yield per stake with
|
||||||
// the live event multipliers. stakeInTx and the views both use it.
|
// the player's bonus and the live event multipliers. stakeInTx and the views both use it.
|
||||||
func drillStats(secs int, yield float64, b Bonus, eventSpeed, eventYield float64) (int, float64) {
|
func drillStats(secs, yield float64, b Bonus, eventSpeed, eventYield float64) (float64, float64) {
|
||||||
return max(1, int(math.Round(float64(secs)/(b.Speed*eventSpeed)))), yield * b.Yield * eventYield
|
return max(1, roundSecs(secs/(b.Speed*eventSpeed))), yield * b.Yield * eventYield
|
||||||
}
|
}
|
||||||
|
|
||||||
// pctText formats a multiplier as a bonus, e.g. 1.1 → "+10%".
|
// pctText formats a multiplier as a bonus, e.g. 1.1 → "+10%".
|
||||||
@@ -81,25 +74,12 @@ func (cfg *GameConfig) validateBonuses() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
okMult := func(v float64) bool { return v == 0 || v >= 1 }
|
okMult := func(v float64) bool { return v == 0 || v >= 1 }
|
||||||
if len(cfg.Attachments.Items) > 0 && cfg.Attachments.SlotsPerTool < 1 {
|
|
||||||
return fmt.Errorf("attachments: slotsPerTool must be at least 1")
|
|
||||||
}
|
|
||||||
names := map[string]bool{}
|
|
||||||
for _, m := range cfg.Attachments.Items {
|
|
||||||
if m.Name == "" || names[m.Name] {
|
|
||||||
return fmt.Errorf("attachments: names must be set and unique (%q)", m.Name)
|
|
||||||
}
|
|
||||||
names[m.Name] = true
|
|
||||||
if m.Price < 0 || !okMult(m.Speed) || !okMult(m.Yield) {
|
|
||||||
return fmt.Errorf("attachment %q: price can't be negative, speed and yield must be at least 1", m.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i, p := range cfg.Advisors.SeatPrices {
|
for i, p := range cfg.Advisors.SeatPrices {
|
||||||
if p < 0 {
|
if p < 0 {
|
||||||
return fmt.Errorf("advisors: seat %d price is negative", i+2)
|
return fmt.Errorf("advisors: seat %d price is negative", i+2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
names = map[string]bool{}
|
names := map[string]bool{}
|
||||||
for _, a := range cfg.Advisors.Items {
|
for _, a := range cfg.Advisors.Items {
|
||||||
if a.Name == "" || names[a.Name] {
|
if a.Name == "" || names[a.Name] {
|
||||||
return fmt.Errorf("advisors: names must be set and unique (%q)", a.Name)
|
return fmt.Errorf("advisors: names must be set and unique (%q)", a.Name)
|
||||||
@@ -114,10 +94,9 @@ func (cfg *GameConfig) validateBonuses() error {
|
|||||||
|
|
||||||
// ------------------------------------------------------------------ in a transaction
|
// ------------------------------------------------------------------ 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 seated advisors and combines them. The advisor rows are
|
||||||
// combines them. Callers that pass a tool have locked its row; the advisor rows are
|
|
||||||
// share-locked so a hire can't change them halfway through the action.
|
// share-locked so a hire can't change them halfway through the action.
|
||||||
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
|
func loadBonus(ctx context.Context, tx pgx.Tx, userID string) (Bonus, error) {
|
||||||
rows, err := tx.Query(ctx, `
|
rows, err := tx.Query(ctx, `
|
||||||
select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
|
select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -127,23 +106,12 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return noBonus, err
|
return noBonus, err
|
||||||
}
|
}
|
||||||
var mods []string
|
return cfg().bonusesFor(advisors), nil
|
||||||
if toolID != "" {
|
|
||||||
rows, err = tx.Query(ctx, `
|
|
||||||
select item from attachment where inventory_item_id = $1 order by item`, toolID)
|
|
||||||
if err != nil {
|
|
||||||
return noBonus, err
|
|
||||||
}
|
|
||||||
if mods, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
|
|
||||||
return noBonus, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cfg().bonusesFor(advisors, mods), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ state
|
// ------------------------------------------------------------------ state
|
||||||
|
|
||||||
// loadBonusState loads the player's attachments, advisors (hired, and the seated ones) and
|
// loadBonusState loads the player's advisors (hired, and the seated ones) and
|
||||||
// advisor seats into the state.
|
// advisor seats into the state.
|
||||||
func loadBonusState(ctx context.Context, db querier, s *State) error {
|
func loadBonusState(ctx context.Context, db querier, s *State) error {
|
||||||
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
|
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
|
||||||
@@ -165,36 +133,20 @@ func loadBonusState(ctx context.Context, db querier, s *State) error {
|
|||||||
s.Advisors = append(s.Advisors, h.Name)
|
s.Advisors = append(s.Advisors, h.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows, err = db.Query(ctx, `
|
return nil
|
||||||
select coalesce(inventory_item_id::text, ''), item from attachment
|
|
||||||
where user_id = $1 order by item`, s.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.Fitted, s.Spare = map[string][]string{}, map[string]int{}
|
|
||||||
var toolID, item string
|
|
||||||
_, err = pgx.ForEachRow(rows, []any{&toolID, &item}, func() error {
|
|
||||||
if toolID == "" {
|
|
||||||
s.Spare[item]++
|
|
||||||
} else {
|
|
||||||
s.Fitted[toolID] = append(s.Fitted[toolID], item)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bonus is the player's advisor bonus (no attachments).
|
// Bonus is the player's advisor bonus.
|
||||||
func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors, nil) }
|
func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors) }
|
||||||
|
|
||||||
// toolBonus is the bonus for stakes of one tool: advisors and its attachments.
|
|
||||||
func (s *State) toolBonus(toolID string) Bonus {
|
|
||||||
return cfg().bonusesFor(s.Advisors, s.Fitted[toolID])
|
|
||||||
}
|
|
||||||
|
|
||||||
// drill is what a stake of the tool on a well of the resource would take and return now.
|
// drill is what a stake of the tool on a well of the resource would take and return now.
|
||||||
func (s *State) drill(t Tool, item *StoreItem, resource string) (int, float64) {
|
func (s *State) drill(t Tool, item *StoreItem, resource string) (float64, float64) {
|
||||||
return drillStats(item.ToolSecs(t.Tier), item.ToolYield(t.Tier), s.toolBonus(t.ID),
|
return s.drillAt(item, t.Levels(), resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drillAt is what a stake of the item at the levels would take and return now.
|
||||||
|
func (s *State) drillAt(item *StoreItem, lv Levels, resource string) (float64, float64) {
|
||||||
|
return drillStats(item.ToolSecs(lv), item.ToolYield(lv), s.Bonus(),
|
||||||
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
|
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// For("all") counts the "all" event once (cycle events are always saved as "all").
|
||||||
|
func TestPriceBoostsFor(t *testing.T) {
|
||||||
|
b := PriceBoosts{"all": 2, "iron": 1.5}
|
||||||
|
for name, want := range map[string]float64{"all": 2, "iron": 3, "gold": 2} {
|
||||||
|
if got := b.For(name); got != want {
|
||||||
|
t.Errorf("For(%q) = %v, want %v", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := (PriceBoosts{}).For("all"); got != 1 {
|
||||||
|
t.Errorf("empty For(all) = %v, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+313
@@ -0,0 +1,313 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Small server-drawn SVG charts for the admin pages (the Economy page's progression
|
||||||
|
// charts): line series with a point per value and a tooltip (<title>) on each, a linear or
|
||||||
|
// log y axis, shaded x bands, vertical markers, tick marks under the plot and a strip of
|
||||||
|
// ranges. Colors come from CSS classes (admin.css, "charts"), so both themes work.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Chart struct {
|
||||||
|
Title string
|
||||||
|
XLabel, YLabel string
|
||||||
|
LogY, LogX bool
|
||||||
|
XTickVals []float64 // x ticks; default: about 8 even steps
|
||||||
|
YFmt func(float64) string // tick labels; default %g
|
||||||
|
XFmt func(float64) string
|
||||||
|
Series []ChartSeries
|
||||||
|
Bands []ChartBand // shaded x ranges with a label at the top
|
||||||
|
VLines []ChartVLine // vertical markers
|
||||||
|
Ticks []ChartTick // marks under the plot (one row per series)
|
||||||
|
Strip []ChartRange // ranges in a strip under the plot
|
||||||
|
StripLabel string
|
||||||
|
Width, Height float64 // of the plot area's viewBox; defaults 1000×260
|
||||||
|
NoPoints bool // lines only
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartSeries struct {
|
||||||
|
Name string
|
||||||
|
Class string // ch-saved, ch-draft
|
||||||
|
Points []ChartPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartPoint struct {
|
||||||
|
X, Y float64
|
||||||
|
Title string // tooltip
|
||||||
|
Mark bool // drawn as a jump marker
|
||||||
|
Label string // short text drawn next to the point
|
||||||
|
Alt bool // drawn as a hollow diamond, off the line
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartBand struct {
|
||||||
|
X0, X1 float64
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartVLine struct {
|
||||||
|
X float64
|
||||||
|
Class string
|
||||||
|
Label string
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartTick struct {
|
||||||
|
X float64
|
||||||
|
Row int // series index
|
||||||
|
Class string
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartRange struct {
|
||||||
|
X0, X1 float64
|
||||||
|
Row int
|
||||||
|
Class string
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
chartLeft = 64.0
|
||||||
|
chartRight = 14.0
|
||||||
|
chartTop = 30.0
|
||||||
|
chartBottom = 34.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// skip: a point the axes can't place.
|
||||||
|
func (ch *Chart) skip(p ChartPoint) bool {
|
||||||
|
return ch.LogY && p.Y <= 0 || ch.LogX && p.X <= 0 || math.IsInf(p.Y, 0) || math.IsNaN(p.Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG draws the chart. An empty chart draws a note instead.
|
||||||
|
func (ch *Chart) SVG() template.HTML {
|
||||||
|
w, h := ch.Width, ch.Height
|
||||||
|
if w == 0 {
|
||||||
|
w = 1000
|
||||||
|
}
|
||||||
|
if h == 0 {
|
||||||
|
h = 260
|
||||||
|
}
|
||||||
|
tickRows := 0
|
||||||
|
for _, t := range ch.Ticks {
|
||||||
|
tickRows = max(tickRows, t.Row+1)
|
||||||
|
}
|
||||||
|
stripRows := 0
|
||||||
|
for _, r := range ch.Strip {
|
||||||
|
stripRows = max(stripRows, r.Row+1)
|
||||||
|
}
|
||||||
|
extra := float64(tickRows)*8 + float64(stripRows)*8
|
||||||
|
if tickRows > 0 || stripRows > 0 {
|
||||||
|
extra += 6
|
||||||
|
}
|
||||||
|
total := h + extra
|
||||||
|
|
||||||
|
// Ranges.
|
||||||
|
x0, x1 := math.Inf(1), math.Inf(-1)
|
||||||
|
y0, y1 := math.Inf(1), math.Inf(-1)
|
||||||
|
for _, s := range ch.Series {
|
||||||
|
for _, p := range s.Points {
|
||||||
|
if ch.skip(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x0, x1 = min(x0, p.X), max(x1, p.X)
|
||||||
|
y0, y1 = min(y0, p.Y), max(y1, p.Y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, b := range ch.Bands {
|
||||||
|
x0, x1 = min(x0, b.X0), max(x1, b.X1)
|
||||||
|
}
|
||||||
|
if math.IsInf(x0, 0) || math.IsInf(y0, 0) {
|
||||||
|
return template.HTML(`<p class="muted small">No data.</p>`)
|
||||||
|
}
|
||||||
|
if x1 == x0 {
|
||||||
|
x1 = x0 + 1
|
||||||
|
}
|
||||||
|
if ch.LogY {
|
||||||
|
y0 = math.Pow(10, math.Floor(math.Log10(y0)))
|
||||||
|
y1 = math.Pow(10, math.Ceil(math.Log10(y1)))
|
||||||
|
if y1 == y0 {
|
||||||
|
y1 = y0 * 10
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
y0 = min(0, y0)
|
||||||
|
if y1 <= y0 {
|
||||||
|
y1 = y0 + 1
|
||||||
|
}
|
||||||
|
y1 = niceCeil(y1)
|
||||||
|
}
|
||||||
|
pw, ph := w-chartLeft-chartRight, h-chartTop-chartBottom
|
||||||
|
px := func(x float64) float64 {
|
||||||
|
if ch.LogX {
|
||||||
|
return chartLeft + (math.Log10(max(x, x0))-math.Log10(x0))/(math.Log10(x1)-math.Log10(x0))*pw
|
||||||
|
}
|
||||||
|
return chartLeft + (x-x0)/(x1-x0)*pw
|
||||||
|
}
|
||||||
|
py := func(y float64) float64 {
|
||||||
|
if ch.LogY {
|
||||||
|
return chartTop + ph - (math.Log10(y)-math.Log10(y0))/(math.Log10(y1)-math.Log10(y0))*ph
|
||||||
|
}
|
||||||
|
return chartTop + ph - (y-y0)/(y1-y0)*ph
|
||||||
|
}
|
||||||
|
yf := ch.YFmt
|
||||||
|
if yf == nil {
|
||||||
|
yf = func(v float64) string { return fmt.Sprintf("%g", v) }
|
||||||
|
}
|
||||||
|
xf := ch.XFmt
|
||||||
|
if xf == nil {
|
||||||
|
xf = func(v float64) string { return fmt.Sprintf("%g", v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
esc := template.HTMLEscapeString
|
||||||
|
fmt.Fprintf(&b, `<svg class="chart" viewBox="0 0 %.0f %.0f" role="img" aria-label="%s">`, w, total, esc(ch.Title))
|
||||||
|
// Bands.
|
||||||
|
for i, band := range ch.Bands {
|
||||||
|
if i%2 == 0 {
|
||||||
|
fmt.Fprintf(&b, `<rect class="ch-band" x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>`,
|
||||||
|
px(band.X0), chartTop, max(0, px(band.X1)-px(band.X0)), ph)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `<text class="ch-band-label" x="%.1f" y="%.1f">%s</text>`, px(band.X0)+3, chartTop-4, esc(band.Label))
|
||||||
|
}
|
||||||
|
// Y grid and labels.
|
||||||
|
for _, v := range yTicks(y0, y1, ch.LogY) {
|
||||||
|
y := py(v)
|
||||||
|
fmt.Fprintf(&b, `<line class="ch-grid" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/>`, chartLeft, chartLeft+pw, y, y)
|
||||||
|
fmt.Fprintf(&b, `<text class="ch-ylab" x="%.1f" y="%.1f">%s</text>`, chartLeft-6, y+3.5, esc(yf(v)))
|
||||||
|
}
|
||||||
|
// X axis and labels.
|
||||||
|
fmt.Fprintf(&b, `<line class="ch-axis" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/>`, chartLeft, chartLeft+pw, chartTop+ph, chartTop+ph)
|
||||||
|
xt := ch.XTickVals
|
||||||
|
if xt == nil {
|
||||||
|
xt = xTicks(x0, x1)
|
||||||
|
}
|
||||||
|
for _, v := range xt {
|
||||||
|
if v < x0 || v > x1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `<text class="ch-xlab" x="%.1f" y="%.1f">%s</text>`, px(v), chartTop+ph+13, esc(xf(v)))
|
||||||
|
}
|
||||||
|
if ch.XLabel != "" {
|
||||||
|
fmt.Fprintf(&b, `<text class="ch-title" x="%.1f" y="%.1f" text-anchor="end">%s</text>`, chartLeft+pw, chartTop+ph+27, esc(ch.XLabel))
|
||||||
|
}
|
||||||
|
if ch.YLabel != "" {
|
||||||
|
fmt.Fprintf(&b, `<text class="ch-title" x="%.1f" y="%.1f">%s</text>`, 4.0, 10.0, esc(ch.YLabel))
|
||||||
|
}
|
||||||
|
// Vertical markers.
|
||||||
|
for _, v := range ch.VLines {
|
||||||
|
x := px(v.X)
|
||||||
|
fmt.Fprintf(&b, `<g class="ch-vline %s"><title>%s</title><line x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/><text x="%.1f" y="%.1f">%s</text></g>`,
|
||||||
|
esc(v.Class), esc(v.Title), x, x, chartTop, chartTop+ph, x+2, chartTop+9, esc(v.Label))
|
||||||
|
}
|
||||||
|
// Series: lines, then points.
|
||||||
|
for _, s := range ch.Series {
|
||||||
|
var d strings.Builder
|
||||||
|
for _, p := range s.Points {
|
||||||
|
if p.Alt || ch.skip(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if d.Len() == 0 {
|
||||||
|
d.WriteString("M")
|
||||||
|
} else {
|
||||||
|
d.WriteString("L")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&d, "%.1f %.1f", px(p.X), py(p.Y))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `<path class="ch-line %s" d="%s"/>`, esc(s.Class), d.String())
|
||||||
|
}
|
||||||
|
for _, s := range ch.Series {
|
||||||
|
fmt.Fprintf(&b, `<g class="%s">`, esc(s.Class))
|
||||||
|
for _, p := range s.Points {
|
||||||
|
if ch.skip(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x, y := px(p.X), py(p.Y)
|
||||||
|
switch {
|
||||||
|
case p.Alt:
|
||||||
|
fmt.Fprintf(&b, `<path class="ch-alt" d="M%.1f %.1fl4.5 4.5-4.5 4.5-4.5-4.5z"><title>%s</title></path>`, x, y-4.5, esc(p.Title))
|
||||||
|
case p.Mark:
|
||||||
|
fmt.Fprintf(&b, `<g class="ch-jump"><title>%s</title><circle cx="%.1f" cy="%.1f" r="7"/><circle class="ch-pt" cx="%.1f" cy="%.1f" r="3"/></g>`,
|
||||||
|
esc(p.Title), x, y, x, y)
|
||||||
|
case !ch.NoPoints:
|
||||||
|
fmt.Fprintf(&b, `<circle class="ch-pt" cx="%.1f" cy="%.1f" r="3"><title>%s</title></circle>`, x, y, esc(p.Title))
|
||||||
|
}
|
||||||
|
if p.Label != "" {
|
||||||
|
fmt.Fprintf(&b, `<text class="ch-plab" x="%.1f" y="%.1f">%s</text>`, x+6, y-6, esc(p.Label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(`</g>`)
|
||||||
|
}
|
||||||
|
// Ticks and strip under the plot.
|
||||||
|
under := h + 2
|
||||||
|
for _, t := range ch.Ticks {
|
||||||
|
x, y := px(t.X), under+float64(t.Row)*8
|
||||||
|
fmt.Fprintf(&b, `<line class="ch-tick %s" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"><title>%s</title></line>`,
|
||||||
|
esc(t.Class), x, x, y, y+6, esc(t.Title))
|
||||||
|
}
|
||||||
|
under += float64(tickRows) * 8
|
||||||
|
if tickRows > 0 {
|
||||||
|
under += 2
|
||||||
|
}
|
||||||
|
for _, r := range ch.Strip {
|
||||||
|
y := under + float64(r.Row)*8
|
||||||
|
fmt.Fprintf(&b, `<rect class="ch-range %s" x="%.1f" y="%.1f" width="%.1f" height="6"><title>%s</title></rect>`,
|
||||||
|
esc(r.Class), px(r.X0), y, max(1.5, px(r.X1)-px(r.X0)), esc(r.Title))
|
||||||
|
}
|
||||||
|
b.WriteString(`</svg>`)
|
||||||
|
return template.HTML(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend is the chart's series names with their swatches.
|
||||||
|
func (ch *Chart) Legend() template.HTML {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<span class="ch-legend">`)
|
||||||
|
for _, s := range ch.Series {
|
||||||
|
fmt.Fprintf(&b, `<span class="%s"><i class="ch-swatch"></i>%s</span>`, template.HTMLEscapeString(s.Class), template.HTMLEscapeString(s.Name))
|
||||||
|
}
|
||||||
|
b.WriteString(`</span>`)
|
||||||
|
return template.HTML(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func niceCeil(v float64) float64 {
|
||||||
|
if v <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
p := math.Pow(10, math.Floor(math.Log10(v)))
|
||||||
|
for _, m := range []float64{1, 2, 2.5, 5, 10} {
|
||||||
|
if m*p >= v {
|
||||||
|
return m * p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 10 * p
|
||||||
|
}
|
||||||
|
|
||||||
|
func yTicks(y0, y1 float64, log bool) []float64 {
|
||||||
|
var out []float64
|
||||||
|
if log {
|
||||||
|
decades := math.Log10(y1) - math.Log10(y0)
|
||||||
|
for e := math.Log10(y0); e <= math.Log10(y1)+1e-9; e++ {
|
||||||
|
v := math.Pow(10, math.Round(e))
|
||||||
|
out = append(out, v)
|
||||||
|
if decades <= 3 && e+1 <= math.Log10(y1)+1e-9 {
|
||||||
|
out = append(out, 3*v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
step := niceCeil((y1 - y0) / 5)
|
||||||
|
for v := y0; v <= y1+step*1e-9; v += step {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func xTicks(x0, x1 float64) []float64 {
|
||||||
|
step := niceCeil((x1 - x0) / 8)
|
||||||
|
var out []float64
|
||||||
|
for v := math.Ceil(x0/step) * step; v <= x1+step*1e-9; v += step {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Claim-all against a real database: TestClaimGolden plays fixed scenarios (one-well and
|
||||||
|
// multi-well drills on shared wells, wells emptied mid-claim, redeploy on and off, every
|
||||||
|
// scope, reach 1, 3 and 5) and compares what they leave in the database with
|
||||||
|
// testdata/claim_golden.txt; TestClaimAllPerf times claim-all and counts its statements.
|
||||||
|
// Both need MM_TEST_DATABASE, a migrated database of their own (scripts/wt makes one:
|
||||||
|
// mm_<task>); without it they are skipped. MM_CLAIM_GOLDEN_WRITE=1 rewrites the golden file.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countTracer counts the statements a pool sends and keeps the slowest.
|
||||||
|
type countTracer struct {
|
||||||
|
n atomic.Int64
|
||||||
|
mu sync.Mutex
|
||||||
|
slow []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type traceStart struct {
|
||||||
|
sql string
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, d pgx.TraceQueryStartData) context.Context {
|
||||||
|
c.n.Add(1)
|
||||||
|
return context.WithValue(ctx, c, traceStart{d.SQL, time.Now()})
|
||||||
|
}
|
||||||
|
func (c *countTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryEndData) {
|
||||||
|
if s, ok := ctx.Value(c).(traceStart); ok {
|
||||||
|
if d := time.Since(s.at); d > 10*time.Millisecond {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.slow = append(c.slow, fmt.Sprintf("%v %s", d.Round(time.Millisecond), strings.Join(strings.Fields(s.sql), " ")))
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (c *countTracer) TraceBatchStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceBatchStartData) context.Context {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
func (c *countTracer) TraceBatchQuery(context.Context, *pgx.Conn, pgx.TraceBatchQueryData) {
|
||||||
|
c.n.Add(1)
|
||||||
|
}
|
||||||
|
func (c *countTracer) TraceBatchEnd(context.Context, *pgx.Conn, pgx.TraceBatchEndData) {}
|
||||||
|
|
||||||
|
func testDB(t *testing.T) (*pgxpool.Pool, *countTracer) {
|
||||||
|
t.Helper()
|
||||||
|
name := os.Getenv("MM_TEST_DATABASE")
|
||||||
|
if name == "" {
|
||||||
|
t.Skip("MM_TEST_DATABASE not set")
|
||||||
|
}
|
||||||
|
if name == "moonminers" {
|
||||||
|
t.Fatal("MM_TEST_DATABASE must not be the game's database")
|
||||||
|
}
|
||||||
|
if !loadDotEnv(".env") {
|
||||||
|
loadDotEnv("../.env")
|
||||||
|
}
|
||||||
|
os.Setenv("DB_DATABASE", name)
|
||||||
|
pc, err := pgxpool.ParseConfig(appDSN())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr := &countTracer{}
|
||||||
|
pc.ConnConfig.Tracer = tr
|
||||||
|
db, err := pgxpool.NewWithConfig(context.Background(), pc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
return db, tr
|
||||||
|
}
|
||||||
|
|
||||||
|
// fx builds players straight in the database and names every row it makes (labels), so
|
||||||
|
// what a claim leaves can be compared across runs.
|
||||||
|
type fx struct {
|
||||||
|
t *testing.T
|
||||||
|
ctx context.Context
|
||||||
|
db *pgxpool.Pool
|
||||||
|
run uint32 // the first part of the ids the fixture makes (orders them as made)
|
||||||
|
ids int
|
||||||
|
label map[string]string // id → label
|
||||||
|
res map[string]int // resource name → id
|
||||||
|
resN []string // resource names, sorted
|
||||||
|
clock int // seconds after base for the next stake
|
||||||
|
action int
|
||||||
|
}
|
||||||
|
|
||||||
|
var fxBase = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func newFx(t *testing.T, db *pgxpool.Pool) *fx {
|
||||||
|
f := &fx{t: t, ctx: context.Background(), db: db, label: map[string]string{}, res: map[string]int{},
|
||||||
|
run: rand.Uint32()}
|
||||||
|
rows, err := db.Query(f.ctx, "select id, name from resource order by name")
|
||||||
|
f.must(err)
|
||||||
|
var id int
|
||||||
|
var n string
|
||||||
|
_, err = pgx.ForEachRow(rows, []any{&id, &n}, func() error {
|
||||||
|
f.res[n] = id
|
||||||
|
f.resN = append(f.resN, n)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
f.must(err)
|
||||||
|
if len(f.resN) < 5 {
|
||||||
|
t.Fatalf("resources: %v", f.resN)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) must(err error) {
|
||||||
|
f.t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) id(q string, args ...any) string {
|
||||||
|
f.t.Helper()
|
||||||
|
var id string
|
||||||
|
f.must(f.db.QueryRow(f.ctx, q, args...).Scan(&id))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) user(label string, onboarding []string, advisors ...string) string {
|
||||||
|
if onboarding == nil {
|
||||||
|
onboarding = []string{}
|
||||||
|
}
|
||||||
|
u := f.id("insert into users(name, onboarding) values ($1, $2) returning id::text", label, onboarding)
|
||||||
|
f.label[u] = label
|
||||||
|
f.t.Cleanup(func() {
|
||||||
|
f.db.Exec(f.ctx, "delete from event_log where user_id = $1", u)
|
||||||
|
f.db.Exec(f.ctx, "delete from users where id = $1", u)
|
||||||
|
})
|
||||||
|
_, err := f.db.Exec(f.ctx, "insert into bank_account(user_id, balance) values ($1, 1000)", u)
|
||||||
|
f.must(err)
|
||||||
|
_, err = f.db.Exec(f.ctx, `insert into resource_account(resource_id, user_id, balance)
|
||||||
|
select id, $1, 0.1 * id from resource`, u)
|
||||||
|
f.must(err)
|
||||||
|
for i, a := range advisors {
|
||||||
|
_, err = f.db.Exec(f.ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", u, a, i+1)
|
||||||
|
f.must(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// moon makes a moon with wells of the given resources (indexes into resN) and supplies;
|
||||||
|
// the wells are labelled <moon>.<i>.
|
||||||
|
func (f *fx) moon(user, label string, res []int, supply []float64) []string {
|
||||||
|
m := f.id(`insert into staking_source(name, description, user_id, address, grade)
|
||||||
|
values ($1, '', $2, '', 'C') returning id::text`, label, user)
|
||||||
|
f.label[m] = label
|
||||||
|
var wells []string
|
||||||
|
for i, r := range res {
|
||||||
|
f.ids++
|
||||||
|
w := f.id("insert into resource_well(id, resource_id, source_id, supply) values ($1, $2, $3, $4) returning id::text",
|
||||||
|
fmt.Sprintf("%08x-0000-4000-8000-%012x", f.run, f.ids), f.res[f.resN[r%len(f.resN)]], m, supply[i])
|
||||||
|
f.label[w] = fmt.Sprintf("%s.%d", label, i)
|
||||||
|
wells = append(wells, w)
|
||||||
|
}
|
||||||
|
return wells
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) tool(user, label string, item, yl, cl int) string {
|
||||||
|
id := f.id("insert into inventory_item(user_id, store_item_id, yield_level, cycle_level) values ($1, $2, $3, $4) returning id::text",
|
||||||
|
user, item, yl, cl)
|
||||||
|
f.label[id] = label
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// stake puts a stake in: ready (started at the next tick of the fixture clock, long ago)
|
||||||
|
// or still running.
|
||||||
|
func (f *fx) stake(user, label, tool, well string, amount float64, reach int, ready bool) string {
|
||||||
|
f.clock++
|
||||||
|
at, secs := fxBase.Add(time.Duration(f.clock)*time.Second), 30.0
|
||||||
|
if !ready {
|
||||||
|
at, secs = time.Now().Add(time.Duration(f.clock)*time.Second), 1e8
|
||||||
|
}
|
||||||
|
id := f.id(`insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7) returning id::text`, user, well, tool, secs, amount, reach, at)
|
||||||
|
f.label[id] = label
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// finish makes every running stake of the player ready, in the order of their tools' labels
|
||||||
|
// (deterministic, unlike the random ids that order stakes started together).
|
||||||
|
func (f *fx) finish(user string) {
|
||||||
|
rows, err := f.db.Query(f.ctx, `select id::text, inventory_item_id::text from staking_event
|
||||||
|
where user_id = $1 and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`, user)
|
||||||
|
f.must(err)
|
||||||
|
type st struct{ ID, Tool string }
|
||||||
|
stakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[st])
|
||||||
|
f.must(err)
|
||||||
|
sort.Slice(stakes, func(i, j int) bool { return f.label[stakes[i].Tool] < f.label[stakes[j].Tool] })
|
||||||
|
for _, s := range stakes {
|
||||||
|
f.clock++
|
||||||
|
_, err := f.db.Exec(f.ctx, "update staking_event set created_at = $2, duration_in_secs = least(duration_in_secs, 30) where id = $1",
|
||||||
|
s.ID, fxBase.Add(time.Duration(f.clock)*time.Second))
|
||||||
|
f.must(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fnum(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
|
||||||
|
|
||||||
|
// claim runs claimReady and writes the result and the player's whole state to out.
|
||||||
|
func (f *fx) claim(out *strings.Builder, user string, scope ClaimScope, what string) {
|
||||||
|
f.action++
|
||||||
|
var lastLog int64
|
||||||
|
f.must(f.db.QueryRow(f.ctx, "select coalesce(max(id), 0) from event_log where user_id = $1", user).Scan(&lastLog))
|
||||||
|
res, err := claimReady(f.ctx, f.db, user, scope)
|
||||||
|
fmt.Fprintf(out, "== %s %s: %s\n", f.label[user], what, f.lbl(scope.MoonID+scope.WellID))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(out, "error: %v\n", err)
|
||||||
|
} else {
|
||||||
|
var fin []string
|
||||||
|
for _, m := range res.Finished {
|
||||||
|
fin = append(fin, f.label[m.ID]+"="+m.Name)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "claimed %d redeployed %d total %s stopped %d finished %v\n",
|
||||||
|
res.Claimed, res.Redeployed, fnum(res.Total), res.Stopped, fin)
|
||||||
|
}
|
||||||
|
f.dump(out, user, lastLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) lbl(id string) string {
|
||||||
|
if l, ok := f.label[id]; ok {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) dump(out *strings.Builder, user string, lastLog int64) {
|
||||||
|
q := func(sql string, args ...any) pgx.Rows {
|
||||||
|
rows, err := f.db.Query(f.ctx, sql, args...)
|
||||||
|
f.must(err)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
// Name the stakes this action made: tool@action.
|
||||||
|
rows := q("select id::text, inventory_item_id::text from staking_event where user_id = $1", user)
|
||||||
|
var id, tool string
|
||||||
|
_, err := pgx.ForEachRow(rows, []any{&id, &tool}, func() error {
|
||||||
|
if _, ok := f.label[id]; !ok {
|
||||||
|
f.label[id] = fmt.Sprintf("%s@%d", f.label[tool], f.action)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
f.must(err)
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
rows = q(`select resource_well.id::text, supply from resource_well
|
||||||
|
join staking_source on staking_source.id = source_id where user_id = $1`, user)
|
||||||
|
var supply float64
|
||||||
|
_, err = pgx.ForEachRow(rows, []any{&id, &supply}, func() error {
|
||||||
|
lines = append(lines, fmt.Sprintf("well %s %s", f.lbl(id), fnum(supply)))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
f.must(err)
|
||||||
|
sort.Strings(lines)
|
||||||
|
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||||
|
|
||||||
|
lines = nil
|
||||||
|
rows = q(`select resource.name, balance from resource_account join resource on resource.id = resource_id
|
||||||
|
where user_id = $1`, user)
|
||||||
|
var name string
|
||||||
|
var bal float64
|
||||||
|
_, err = pgx.ForEachRow(rows, []any{&name, &bal}, func() error {
|
||||||
|
lines = append(lines, fmt.Sprintf("balance %s %s", name, fnum(bal)))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
f.must(err)
|
||||||
|
sort.Strings(lines)
|
||||||
|
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||||
|
|
||||||
|
var flags []string
|
||||||
|
f.must(f.db.QueryRow(f.ctx, "select onboarding from users where id = $1", user).Scan(&flags))
|
||||||
|
slices.Sort(flags)
|
||||||
|
fmt.Fprintf(out, "onboarding %v\n", flags)
|
||||||
|
|
||||||
|
lines = nil
|
||||||
|
rows = q(`select staking_event.id::text, well_id::text, inventory_item_id::text, duration_in_secs, stake_amount,
|
||||||
|
staking_event.reach, claim_event.claim_amount,
|
||||||
|
coalesce((select string_agg(cw.well_id::text || '=' || cw.amount::text, ',') from claim_well cw
|
||||||
|
where cw.claim_event_id = claim_event.id), '')
|
||||||
|
from staking_event left join claim_event on claim_event.staking_event_id = staking_event.id
|
||||||
|
where staking_event.user_id = $1`, user)
|
||||||
|
var well string
|
||||||
|
var dur, amount float64
|
||||||
|
var reach int
|
||||||
|
var claim *float64
|
||||||
|
var cw string
|
||||||
|
_, err = pgx.ForEachRow(rows, []any{&id, &well, &tool, &dur, &amount, &reach, &claim, &cw}, func() error {
|
||||||
|
c := "running"
|
||||||
|
if claim != nil {
|
||||||
|
c = "claimed " + fnum(*claim)
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, p := range strings.Split(cw, ",") {
|
||||||
|
if w, a, ok := strings.Cut(p, "="); ok {
|
||||||
|
parts = append(parts, f.lbl(w)+"="+a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(parts)
|
||||||
|
lines = append(lines, fmt.Sprintf("stake %s well %s tool %s secs %s amount %s reach %d %s %v",
|
||||||
|
f.lbl(id), f.lbl(well), f.lbl(tool), fnum(dur), fnum(amount), reach, c, parts))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
f.must(err)
|
||||||
|
sort.Strings(lines)
|
||||||
|
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||||
|
|
||||||
|
// The event log in order; runs of stake_stopped (ordered by start time only, ties
|
||||||
|
// at random) sorted.
|
||||||
|
lines = nil
|
||||||
|
rows = q("select kind, data::text from event_log where user_id = $1 and id > $2 order by id", user, lastLog)
|
||||||
|
var kind, data string
|
||||||
|
var run []string
|
||||||
|
flush := func() {
|
||||||
|
slices.Sort(run)
|
||||||
|
lines = append(lines, run...)
|
||||||
|
run = nil
|
||||||
|
}
|
||||||
|
_, err = pgx.ForEachRow(rows, []any{&kind, &data}, func() error {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(data), &m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
slices.Sort(keys)
|
||||||
|
var b strings.Builder
|
||||||
|
for _, k := range keys {
|
||||||
|
v := m[k]
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
v = f.lbl(s)
|
||||||
|
} else if x, ok := v.(float64); ok {
|
||||||
|
v = fnum(x)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %s=%v", k, v)
|
||||||
|
}
|
||||||
|
line := "log " + kind + b.String()
|
||||||
|
if kind == "stake_stopped" {
|
||||||
|
run = append(run, line)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
lines = append(lines, line)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
f.must(err)
|
||||||
|
flush()
|
||||||
|
if len(lines) > 0 {
|
||||||
|
out.WriteString(strings.Join(lines, "\n") + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withConfig runs fn with the store items' reach replaced (item id → reach).
|
||||||
|
func withReach(reach map[int]int, fn func()) {
|
||||||
|
old := cfg()
|
||||||
|
c := *old
|
||||||
|
c.StoreItems = slices.Clone(old.StoreItems)
|
||||||
|
for i := range c.StoreItems {
|
||||||
|
if r, ok := reach[c.StoreItems[i].ID]; ok {
|
||||||
|
c.StoreItems[i].Reach = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
effective.Store(&c)
|
||||||
|
defer effective.Store(old)
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// seqStakeIDs makes new staking_event ids come from a sequence, in the order the stakes
|
||||||
|
// are made, whether the database makes them (the column default) or the server
|
||||||
|
// (newStakeID): stakes started together are ordered by id, which is otherwise random.
|
||||||
|
func seqStakeIDs(t *testing.T, db *pgxpool.Pool) {
|
||||||
|
ctx := context.Background()
|
||||||
|
admin, err := pgxpool.New(ctx, adminDSN())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer admin.Close()
|
||||||
|
const id = `('00000000-0000-4000-8000-' || lpad(to_hex(nextval('claim_test_seq')), 12, '0'))::uuid`
|
||||||
|
for _, q := range []string{
|
||||||
|
"create sequence if not exists claim_test_seq",
|
||||||
|
"grant usage, select on sequence claim_test_seq to " + pgx.Identifier{env("DB_USER", "moonminers")}.Sanitize(),
|
||||||
|
"alter table staking_event alter column id set default " + id,
|
||||||
|
} {
|
||||||
|
if _, err := admin.Exec(ctx, q); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldID := newStakeID
|
||||||
|
newStakeID = func() string {
|
||||||
|
var s string
|
||||||
|
if err := db.QueryRow(ctx, "select "+id+"::text").Scan(&s); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
newStakeID = oldID
|
||||||
|
admin, err := pgxpool.New(ctx, adminDSN())
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer admin.Close()
|
||||||
|
if _, err := admin.Exec(ctx, "alter table staking_event alter column id set default gen_random_uuid()"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimGolden(t *testing.T) {
|
||||||
|
db, _ := testDB(t)
|
||||||
|
seqStakeIDs(t, db)
|
||||||
|
ctx := context.Background()
|
||||||
|
// Live events for the redeployed stakes' amounts and durations; removed at the end.
|
||||||
|
var evs []int
|
||||||
|
for _, e := range []struct {
|
||||||
|
kind, res string
|
||||||
|
mult float64
|
||||||
|
}{{"cycle_multiplier", "all", 1.5}, {"yield_multiplier", "Sollux", 1.25}, {"yield_multiplier", "Azurium", 0.8}} {
|
||||||
|
var id int
|
||||||
|
err := db.QueryRow(ctx, `insert into live_event(name, kind, params, starts_at, ends_at)
|
||||||
|
values ('claim test', $1, $2, now() - interval '1 day', now() + interval '1 day') returning id`,
|
||||||
|
e.kind, map[string]any{"resource": e.res, "multiplier": e.mult}).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
evs = append(evs, id)
|
||||||
|
}
|
||||||
|
defer db.Exec(ctx, "delete from live_event where id = any($1)", evs)
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
run := func(cfgName string) {
|
||||||
|
f := newFx(t, db)
|
||||||
|
claimScenarios(f, &out, cfgName)
|
||||||
|
}
|
||||||
|
run("reach5")
|
||||||
|
withReach(map[int]int{1: 1, 2: 1, 3: 3, 4: 2}, func() { run("mixed") })
|
||||||
|
withReach(map[int]int{1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1}, func() { run("reach1") })
|
||||||
|
|
||||||
|
got := out.String()
|
||||||
|
const path = "testdata/claim_golden.txt"
|
||||||
|
if os.Getenv("MM_CLAIM_GOLDEN_WRITE") == "1" {
|
||||||
|
if err := os.MkdirAll("testdata", 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != string(want) {
|
||||||
|
os.WriteFile(os.TempDir()+"/claim_golden.got", []byte(got), 0o644)
|
||||||
|
gl, wl := strings.Split(got, "\n"), strings.Split(string(want), "\n")
|
||||||
|
for i := 0; i < min(len(gl), len(wl)); i++ {
|
||||||
|
if gl[i] != wl[i] {
|
||||||
|
t.Fatalf("differs at line %d (full output in %s/claim_golden.got):\n got: %s\nwant: %s",
|
||||||
|
i+1, os.TempDir(), gl[i], wl[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("differs in length: got %d lines, want %d", len(gl), len(wl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimScenarios plays every scenario for one config; p prefixes the labels.
|
||||||
|
func claimScenarios(f *fx, out *strings.Builder, p string) {
|
||||||
|
all := ClaimScope{}
|
||||||
|
redeploy := ClaimScope{Redeploy: true}
|
||||||
|
|
||||||
|
// 1. One-well drills overdrilling a well: the third claim empties it and stops the
|
||||||
|
// fourth (ready, later in the list) and a running one; a multi-well drill on the
|
||||||
|
// emptied well; claim-all with redeploy, then the redeployed drills again.
|
||||||
|
u := f.user(p+"-overdrill", nil)
|
||||||
|
w := f.moon(u, p+"-m1", []int{3, 0, 4}, []float64{10, 50, 5})
|
||||||
|
for i, item := range []int{1, 2, 1, 3} {
|
||||||
|
f.stake(u, fmt.Sprintf("%s-s%d", p, i), f.tool(u, fmt.Sprintf("%s-t%d", p, i), item, 0, 0), w[0], 4, 1, true)
|
||||||
|
}
|
||||||
|
f.stake(u, p+"-run", f.tool(u, p+"-t4", 1, 0, 0), w[0], 3, 1, false)
|
||||||
|
f.stake(u, p+"-wide", f.tool(u, p+"-t5", 5, 1, 0), w[2], 7, 5, true)
|
||||||
|
f.stake(u, p+"-az", f.tool(u, p+"-t6", 2, 0, 1), w[1], 9, 1, true)
|
||||||
|
f.claim(out, u, redeploy, "claim-all redeploy")
|
||||||
|
f.finish(u)
|
||||||
|
f.claim(out, u, redeploy, "claim-all redeploy 2")
|
||||||
|
f.finish(u)
|
||||||
|
f.claim(out, u, all, "claim-all")
|
||||||
|
|
||||||
|
// 2. Multi-well drills on a 7-well moon (reach 5 covers part of it) and a 4-well moon,
|
||||||
|
// wells emptied by multi-well claims stopping one-well drills; a moon that finishes.
|
||||||
|
u = f.user(p+"-wide", []string{"market"}, "Ada Voss", "Rook Halvorsen")
|
||||||
|
w7 := f.moon(u, p+"-m7", []int{0, 1, 2, 3, 4, 0, 1}, []float64{3, 8, 0, 12, 2.5, 6, 1})
|
||||||
|
w4 := f.moon(u, p+"-m4", []int{4, 3, 2, 1}, []float64{2, 2, 1, 0.5})
|
||||||
|
n := 0
|
||||||
|
tl := func(item, yl, cl int) string {
|
||||||
|
n++
|
||||||
|
return f.tool(u, fmt.Sprintf("%s-w%d", p, n), item, yl, cl)
|
||||||
|
}
|
||||||
|
f.stake(u, p+"-a1", tl(4, 0, 0), w7[0], 9, 5, true)
|
||||||
|
f.stake(u, p+"-a2", tl(1, 0, 0), w7[3], 5, 1, true)
|
||||||
|
f.stake(u, p+"-a3", tl(5, 2, 1), w7[6], 6, 5, true)
|
||||||
|
f.stake(u, p+"-a4", tl(1, 1, 0), w7[1], 2, 1, true)
|
||||||
|
f.stake(u, p+"-a5", tl(3, 0, 0), w7[2], 4, 3, true)
|
||||||
|
f.stake(u, p+"-a6", tl(1, 0, 0), w7[4], 3, 1, false)
|
||||||
|
f.stake(u, p+"-a7", tl(6, 0, 0), w7[5], 20, 5, false)
|
||||||
|
f.stake(u, p+"-b1", tl(2, 0, 0), w4[0], 1.5, 1, true)
|
||||||
|
f.stake(u, p+"-b2", tl(7, 0, 0), w4[1], 3, 5, true)
|
||||||
|
f.stake(u, p+"-b3", tl(1, 0, 0), w4[3], 1, 1, true)
|
||||||
|
f.stake(u, p+"-b4", tl(4, 0, 0), w4[2], 2, 3, true)
|
||||||
|
m7 := f.moonOf(w7[0])
|
||||||
|
f.claim(out, u, ClaimScope{MoonID: m7}, "claim-moon m7")
|
||||||
|
f.claim(out, u, ClaimScope{MoonID: m7, Redeploy: true}, "claim-moon m7 redeploy")
|
||||||
|
f.claim(out, u, ClaimScope{MoonID: f.moonOf(w4[0]), Wide: true, Redeploy: true}, "claim-wide m4 redeploy")
|
||||||
|
f.claim(out, u, ClaimScope{WellID: w4[0], Redeploy: true}, "claim-well m4.0 redeploy")
|
||||||
|
f.claim(out, u, ClaimScope{WellID: w4[3]}, "claim-well m4.3")
|
||||||
|
f.finish(u)
|
||||||
|
f.claim(out, u, redeploy, "claim-all redeploy")
|
||||||
|
f.finish(u)
|
||||||
|
f.claim(out, u, redeploy, "claim-all redeploy 2")
|
||||||
|
f.finish(u)
|
||||||
|
f.claim(out, u, all, "claim-all")
|
||||||
|
f.claim(out, u, all, "claim-all nothing")
|
||||||
|
|
||||||
|
// 3. A tool with a second unclaimed stake: redeploying it fails the whole claim.
|
||||||
|
u = f.user(p+"-busy", []string{"all"})
|
||||||
|
w = f.moon(u, p+"-mb", []int{0, 1}, []float64{20, 20})
|
||||||
|
t := f.tool(u, p+"-bt", 1, 0, 0)
|
||||||
|
f.stake(u, p+"-bs1", t, w[0], 2, 1, true)
|
||||||
|
f.stake(u, p+"-bs2", t, w[1], 2, 1, false)
|
||||||
|
f.stake(u, p+"-bs3", f.tool(u, p+"-bt2", 2, 0, 0), w[1], 2, 1, true)
|
||||||
|
f.claim(out, u, redeploy, "claim-all redeploy busy")
|
||||||
|
f.claim(out, u, all, "claim-all busy")
|
||||||
|
|
||||||
|
// 4. Many moons and drills with tight wells, seeded: three rounds of claim-all with
|
||||||
|
// redeploy, then one without.
|
||||||
|
u = f.user(p+"-many", nil, "Juno Park")
|
||||||
|
rnd := rand.New(rand.NewSource(7))
|
||||||
|
var moons [][]string
|
||||||
|
for m := 0; m < 25; m++ {
|
||||||
|
k := 1 + rnd.Intn(7)
|
||||||
|
res, sup := make([]int, k), make([]float64, k)
|
||||||
|
for i := range res {
|
||||||
|
res[i] = rnd.Intn(5)
|
||||||
|
sup[i] = float64(rnd.Intn(40)) / 2
|
||||||
|
if rnd.Intn(6) == 0 {
|
||||||
|
sup[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moons = append(moons, f.moon(u, fmt.Sprintf("%s-n%d", p, m), res, sup))
|
||||||
|
}
|
||||||
|
for i := 0; i < 120; i++ {
|
||||||
|
ws := moons[rnd.Intn(len(moons))]
|
||||||
|
item := 1 + rnd.Intn(7)
|
||||||
|
reach := 1
|
||||||
|
if rnd.Intn(2) == 0 {
|
||||||
|
reach = []int{2, 3, 5}[rnd.Intn(3)]
|
||||||
|
}
|
||||||
|
tool := f.tool(u, fmt.Sprintf("%s-x%03d", p, i), item, rnd.Intn(3), rnd.Intn(3))
|
||||||
|
f.stake(u, fmt.Sprintf("%s-y%03d", p, i), tool, ws[rnd.Intn(len(ws))], float64(1+rnd.Intn(16))/2, reach, rnd.Intn(5) != 0)
|
||||||
|
}
|
||||||
|
for r := 0; r < 3; r++ {
|
||||||
|
f.claim(out, u, redeploy, fmt.Sprintf("claim-all redeploy round %d", r))
|
||||||
|
f.finish(u)
|
||||||
|
}
|
||||||
|
f.claim(out, u, all, "claim-all final")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fx) moonOf(well string) string {
|
||||||
|
return f.id("select source_id::text from resource_well where id = $1", well)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClaimAllPerf times claim-all for players with n ready drills, and counts its
|
||||||
|
// statements: one whole-moon drill per moon of three wells, or (crowded) n drills on 25
|
||||||
|
// moons, a third of them one-well drills, more than the wells hold.
|
||||||
|
func TestClaimAllPerf(t *testing.T) {
|
||||||
|
db, tr := testDB(t)
|
||||||
|
f := newFx(t, db)
|
||||||
|
type perfRun struct {
|
||||||
|
n int
|
||||||
|
redeploy bool
|
||||||
|
crowded bool
|
||||||
|
}
|
||||||
|
runs := []perfRun{{50, false, false}, {50, true, false}, {200, false, false}, {200, true, false},
|
||||||
|
{900, false, false}, {900, true, false}, {900, false, true}, {900, true, true}}
|
||||||
|
for _, run := range runs {
|
||||||
|
n, moons, supply := run.n, run.n, []float64{40, 30, 3}
|
||||||
|
if run.crowded {
|
||||||
|
moons, supply = 25, []float64{300, 200, 8}
|
||||||
|
}
|
||||||
|
u := f.user(fmt.Sprintf("perf-%d-%v-%v", n, run.redeploy, run.crowded), nil)
|
||||||
|
for m := 0; m < moons; m++ {
|
||||||
|
f.moon(u, fmt.Sprintf("p%d-%d", n, m), []int{m, m + 1, m + 2}, supply)
|
||||||
|
}
|
||||||
|
_, err := db.Exec(f.ctx, `
|
||||||
|
insert into inventory_item(user_id, store_item_id) select $1, 1 + (g % 7) from generate_series(1, $2) g`, u, n)
|
||||||
|
f.must(err)
|
||||||
|
_, err = db.Exec(f.ctx, `
|
||||||
|
with tools as (select id, row_number() over (order by id) r from inventory_item where user_id = $1),
|
||||||
|
moons as (select id, row_number() over (order by name) r from staking_source where user_id = $1)
|
||||||
|
insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
|
||||||
|
select $1, (select id from resource_well where source_id = moons.id order by id offset tools.r % 3 limit 1),
|
||||||
|
tools.id, 10, 5 + tools.r % 4, case when $4 and tools.r % 3 = 0 then 1 else 5 end,
|
||||||
|
$2::timestamptz + tools.r * interval '1 second'
|
||||||
|
from tools join moons on moons.r = (tools.r - 1) % $3 + 1`, u, fxBase, moons, run.crowded)
|
||||||
|
f.must(err)
|
||||||
|
tr.mu.Lock()
|
||||||
|
tr.slow = nil
|
||||||
|
tr.mu.Unlock()
|
||||||
|
start, n0 := time.Now(), tr.n.Load()
|
||||||
|
res, err := claimReady(f.ctx, db, u, ClaimScope{Redeploy: run.redeploy})
|
||||||
|
f.must(err)
|
||||||
|
t.Logf("drills %d redeploy %v crowded %v: claimed %d redeployed %d stopped %d in %v, %d statements",
|
||||||
|
n, run.redeploy, run.crowded, res.Claimed, res.Redeployed, res.Stopped,
|
||||||
|
time.Since(start).Round(time.Millisecond), tr.n.Load()-n0)
|
||||||
|
for _, s := range tr.slow {
|
||||||
|
t.Logf(" slow: %.200s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-38
@@ -1,6 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// The game config (constants, store items and their upgrades, moon types) lives in
|
// The game config (constants, store items and their upgrade tracks, moon types) lives in
|
||||||
// config/game.json, embedded in the binary (`game`). Admins can override its numbers live
|
// config/game.json, embedded in the binary (`game`). Admins can override its numbers live
|
||||||
// (economy.go); read the effective config with cfg(). Store items are also synced into
|
// (economy.go); read the effective config with cfg(). Store items are also synced into
|
||||||
// the database (syncStoreItems), because inventory_item references store_item and the
|
// the database (syncStoreItems), because inventory_item references store_item and the
|
||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -29,9 +31,11 @@ type GameConfig struct {
|
|||||||
StartingTools []int `json:"startingTools"` // store item ids, given free
|
StartingTools []int `json:"startingTools"` // store item ids, given free
|
||||||
// Moon vendors, cheapest first (rollWells in game.go); the first is the default grade.
|
// Moon vendors, cheapest first (rollWells in game.go); the first is the default grade.
|
||||||
MoonGrades []MoonGrade `json:"moonGrades"`
|
MoonGrades []MoonGrade `json:"moonGrades"`
|
||||||
|
// Packs of moons bought at once (buyMoons in game.go), smallest first.
|
||||||
|
MoonPacks []MoonPack `json:"moonPacks,omitempty"`
|
||||||
// Selling a moon back (resale.go).
|
// Selling a moon back (resale.go).
|
||||||
MoonResale MoonResale `json:"moonResale"`
|
MoonResale MoonResale `json:"moonResale"`
|
||||||
// Selling tools and attachments back (resale.go).
|
// Selling tools back (resale.go).
|
||||||
EquipmentResale EquipmentResale `json:"equipmentResale"`
|
EquipmentResale EquipmentResale `json:"equipmentResale"`
|
||||||
// Credits per unit sold, by resource name.
|
// Credits per unit sold, by resource name.
|
||||||
ResourcePrices map[string]float64 `json:"resourcePrices"`
|
ResourcePrices map[string]float64 `json:"resourcePrices"`
|
||||||
@@ -45,10 +49,9 @@ type GameConfig struct {
|
|||||||
MoonNames MoonNames `json:"moonNames"`
|
MoonNames MoonNames `json:"moonNames"`
|
||||||
// Crafting refined items from resources (refinery.go).
|
// Crafting refined items from resources (refinery.go).
|
||||||
Refinery RefineryConfig `json:"refinery"`
|
Refinery RefineryConfig `json:"refinery"`
|
||||||
// Drill attachments and advisors (bonus.go): their combined bonuses are capped here.
|
// Advisors (bonus.go): their combined bonuses are capped here.
|
||||||
BonusCaps BonusCaps `json:"bonusCaps"`
|
BonusCaps BonusCaps `json:"bonusCaps"`
|
||||||
Attachments AttachmentConfig `json:"attachments"`
|
Advisors AdvisorConfig `json:"advisors"`
|
||||||
Advisors AdvisorConfig `json:"advisors"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoonGrade is a moon vendor: its price and the moons it sells. Each well of its moons has
|
// MoonGrade is a moon vendor: its price and the moons it sells. Each well of its moons has
|
||||||
@@ -68,6 +71,30 @@ type MoonGrade struct {
|
|||||||
MinValue float64 `json:"minValue,omitempty"`
|
MinValue float64 `json:"minValue,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoonPack is count moons of one grade bought at once: discount off their price, and each
|
||||||
|
// moon worth at least minValue × its grade's price (re-rolled like a grade's minValue,
|
||||||
|
// whichever floor is higher).
|
||||||
|
type MoonPack struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Discount float64 `json:"discount"`
|
||||||
|
MinValue float64 `json:"minValue,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pack is the moon pack of that size (nil: none, or a single moon).
|
||||||
|
func (cfg *GameConfig) pack(count int) *MoonPack {
|
||||||
|
for i := range cfg.MoonPacks {
|
||||||
|
if cfg.MoonPacks[i].Count == count {
|
||||||
|
return &cfg.MoonPacks[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackPrice is what count moons of the grade cost in this pack.
|
||||||
|
func (p *MoonPack) PackPrice(g *MoonGrade) float64 {
|
||||||
|
return math.Round(float64(p.Count) * g.Price * (1 - p.Discount))
|
||||||
|
}
|
||||||
|
|
||||||
// grade is the moon grade with that name, else the first (lowest) one.
|
// grade is the moon grade with that name, else the first (lowest) one.
|
||||||
func (cfg *GameConfig) grade(name string) *MoonGrade {
|
func (cfg *GameConfig) grade(name string) *MoonGrade {
|
||||||
if i := cfg.gradeIndex(name); i >= 0 {
|
if i := cfg.gradeIndex(name); i >= 0 {
|
||||||
@@ -123,6 +150,10 @@ type MoonType struct {
|
|||||||
|
|
||||||
var game = mustParseGameConfig(gameJSON)
|
var game = mustParseGameConfig(gameJSON)
|
||||||
|
|
||||||
|
// resourceNames are the rows of the resource table (sql/data.sql). Wells are stored by
|
||||||
|
// these names, so the config can price no other resource.
|
||||||
|
var resourceNames = []string{"Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance"}
|
||||||
|
|
||||||
func mustParseGameConfig(data []byte) GameConfig {
|
func mustParseGameConfig(data []byte) GameConfig {
|
||||||
var cfg GameConfig
|
var cfg GameConfig
|
||||||
dec := json.NewDecoder(bytes.NewReader(data))
|
dec := json.NewDecoder(bytes.NewReader(data))
|
||||||
@@ -158,6 +189,14 @@ func (cfg *GameConfig) validate() error {
|
|||||||
if err := cfg.MarketPressure.validate(); err != nil {
|
if err := cfg.MarketPressure.validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for i, p := range cfg.MoonPacks {
|
||||||
|
if p.Count < 2 || p.Discount < 0 || p.Discount >= 1 || p.MinValue < 0 || p.MinValue > 3 {
|
||||||
|
return fmt.Errorf("moon pack %d: count at least 2, discount from 0 to below 1, minValue 0 to 3", i+1)
|
||||||
|
}
|
||||||
|
if i > 0 && p.Count <= cfg.MoonPacks[i-1].Count {
|
||||||
|
return fmt.Errorf("moon packs: smallest first, no two the same size")
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := cfg.MoonResale.validate(); err != nil {
|
if err := cfg.MoonResale.validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -165,10 +204,16 @@ func (cfg *GameConfig) validate() error {
|
|||||||
return fmt.Errorf("equipmentResale.factor must be at least 0 and below 1")
|
return fmt.Errorf("equipmentResale.factor must be at least 0 and below 1")
|
||||||
}
|
}
|
||||||
for name, price := range cfg.ResourcePrices {
|
for name, price := range cfg.ResourcePrices {
|
||||||
|
if !slices.Contains(resourceNames, name) {
|
||||||
|
return fmt.Errorf("resourcePrices: %q is not a resource (%s)", name, strings.Join(resourceNames, ", "))
|
||||||
|
}
|
||||||
if price < 0 {
|
if price < 0 {
|
||||||
return fmt.Errorf("%s price is negative", name)
|
return fmt.Errorf("%s price is negative", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(cfg.StoreItems) == 0 {
|
||||||
|
return fmt.Errorf("no store items")
|
||||||
|
}
|
||||||
ids := map[int]bool{}
|
ids := map[int]bool{}
|
||||||
for _, item := range cfg.StoreItems {
|
for _, item := range cfg.StoreItems {
|
||||||
if item.Price < 0 || item.ClaimAmount < 0 || item.CompletionTimeInSecs < 1 {
|
if item.Price < 0 || item.ClaimAmount < 0 || item.CompletionTimeInSecs < 1 {
|
||||||
@@ -177,23 +222,13 @@ func (cfg *GameConfig) validate() error {
|
|||||||
if item.Reach < 0 || item.Reach > len(cfg.ResourcePrices) {
|
if item.Reach < 0 || item.Reach > len(cfg.ResourcePrices) {
|
||||||
return fmt.Errorf("store item %d: reach must be 0 to %d wells", item.ID, len(cfg.ResourcePrices))
|
return fmt.Errorf("store item %d: reach must be 0 to %d wells", item.ID, len(cfg.ResourcePrices))
|
||||||
}
|
}
|
||||||
for _, u := range item.Upgrades {
|
if err := cfg.validateTracks(item); err != nil {
|
||||||
if u.Price < 0 {
|
return err
|
||||||
return fmt.Errorf("store item %d tier %d: price is negative", item.ID, u.Tier)
|
|
||||||
}
|
|
||||||
if u.Reach < 0 || u.Reach > len(cfg.ResourcePrices) {
|
|
||||||
return fmt.Errorf("store item %d tier %d: reach must be 0 to %d wells", item.ID, u.Tier, len(cfg.ResourcePrices))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if ids[item.ID] {
|
if ids[item.ID] {
|
||||||
return fmt.Errorf("duplicate store item id %d", item.ID)
|
return fmt.Errorf("duplicate store item id %d", item.ID)
|
||||||
}
|
}
|
||||||
ids[item.ID] = true
|
ids[item.ID] = true
|
||||||
for i, u := range item.Upgrades {
|
|
||||||
if u.Tier != i+1 {
|
|
||||||
return fmt.Errorf("store item %d: upgrade tiers must be 1, 2, 3, ... in order", item.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, id := range cfg.StartingTools {
|
for _, id := range cfg.StartingTools {
|
||||||
if !ids[id] {
|
if !ids[id] {
|
||||||
@@ -281,7 +316,8 @@ func (cfg *GameConfig) validateWells() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncStoreItems makes store_item and upgrade_item match the effective config. Store items
|
// syncStoreItems makes store_item match the effective config (the upgrade tracks are read
|
||||||
|
// from the config, not stored). Store items
|
||||||
// that were removed from the file are deleted only if no player owns one (deleting
|
// that were removed from the file are deleted only if no player owns one (deleting
|
||||||
// would cascade to their tools).
|
// would cascade to their tools).
|
||||||
func syncStoreItems(ctx context.Context, db *pgxpool.Pool) error {
|
func syncStoreItems(ctx context.Context, db *pgxpool.Pool) error {
|
||||||
@@ -312,25 +348,6 @@ func syncStoreItemsTx(ctx context.Context, tx pgx.Tx, items []StoreItem) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store item %d: %w", item.ID, err)
|
return fmt.Errorf("store item %d: %w", item.ID, err)
|
||||||
}
|
}
|
||||||
tiers := []int{}
|
|
||||||
for _, u := range item.Upgrades {
|
|
||||||
tiers = append(tiers, u.Tier)
|
|
||||||
_, err := tx.Exec(ctx, `
|
|
||||||
insert into upgrade_item(store_item_id, tier, price, claim_boost)
|
|
||||||
values ($1, $2, $3, $4)
|
|
||||||
on conflict (store_item_id, tier) do update set
|
|
||||||
price = excluded.price, claim_boost = excluded.claim_boost`,
|
|
||||||
item.ID, u.Tier, u.Price, u.ClaimBoost)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("store item %d tier %d: %w", item.ID, u.Tier, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
delete from upgrade_item where store_item_id = $1 and not (tier = any($2))`,
|
|
||||||
item.ID, tiers)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("store item %d: %w", item.ID, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := tx.Exec(ctx, `
|
_, err := tx.Exec(ctx, `
|
||||||
|
|||||||
+149
-98
@@ -3,17 +3,19 @@
|
|||||||
"startingResources": 0,
|
"startingResources": 0,
|
||||||
"startingTools": [1],
|
"startingTools": [1],
|
||||||
"moonGrades": [
|
"moonGrades": [
|
||||||
{"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 23, "wellSupplyMax": 46, "wellCountWeights": {"1": 40, "2": 60},
|
{"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 28, "wellSupplyMax": 56, "wellCountWeights": {"1": 40, "2": 60},
|
||||||
"affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}},
|
"affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}},
|
||||||
{"grade": "C", "price": 6000, "minValue": 1.3, "wellSupplyMin": 216, "wellSupplyMax": 430, "wellCountWeights": {"2": 50, "3": 50},
|
{"grade": "C", "price": 12000, "minValue": 1.3, "wellSupplyMin": 432, "wellSupplyMax": 860, "wellCountWeights": {"2": 50, "3": 50},
|
||||||
"affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}},
|
"affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}},
|
||||||
{"grade": "B", "price": 30000, "wellSupplyMin": 738, "wellSupplyMax": 1968, "wellCountWeights": {"2": 30, "3": 40, "4": 30},
|
{"grade": "B", "price": 90000, "wellSupplyMin": 2214, "wellSupplyMax": 5904, "wellCountWeights": {"2": 30, "3": 40, "4": 30},
|
||||||
"affinities": {"Sollux": 1, "Shadowstone": 1.5, "Azurium": 2, "Novafor": 2, "Nebulance": 1.2}},
|
"affinities": {"Sollux": 1, "Shadowstone": 1.5, "Azurium": 2, "Novafor": 2, "Nebulance": 1.2}},
|
||||||
{"grade": "A", "price": 300000, "wellSupplyMin": 4920, "wellSupplyMax": 12915, "wellCountWeights": {"3": 30, "4": 40, "5": 30},
|
{"grade": "A", "price": 1500000, "wellSupplyMin": 24600, "wellSupplyMax": 64575, "wellCountWeights": {"3": 30, "4": 40, "5": 30},
|
||||||
"affinities": {"Sollux": 0.6, "Shadowstone": 1, "Azurium": 1.5, "Novafor": 2, "Nebulance": 2}},
|
"affinities": {"Sollux": 0.6, "Shadowstone": 1, "Azurium": 1.5, "Novafor": 2, "Nebulance": 2}},
|
||||||
{"grade": "S", "price": 3000000, "wellSupplyMin": 42230, "wellSupplyMax": 103730, "wellCountWeights": {"4": 50, "5": 50},
|
{"grade": "S", "price": 24000000, "wellSupplyMin": 337840, "wellSupplyMax": 829840, "wellCountWeights": {"4": 50, "5": 50},
|
||||||
"affinities": {"Sollux": 0.4, "Shadowstone": 0.6, "Azurium": 1, "Novafor": 2, "Nebulance": 3.5}}
|
"affinities": {"Sollux": 0.4, "Shadowstone": 0.6, "Azurium": 1, "Novafor": 2, "Nebulance": 3.5}}
|
||||||
],
|
],
|
||||||
|
"moonPacks": [{"count": 5, "discount": 0.05, "minValue": 1.4}, {"count": 10, "discount": 0.1, "minValue": 1.5},
|
||||||
|
{"count": 20, "discount": 0.15, "minValue": 1.6}],
|
||||||
"moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600},
|
"moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600},
|
||||||
"equipmentResale": {"factor": 0.5},
|
"equipmentResale": {"factor": 0.5},
|
||||||
"marketVolatility": 0.25,
|
"marketVolatility": 0.25,
|
||||||
@@ -32,115 +34,177 @@
|
|||||||
"description": "A hovering scout drone that skims ore off the surface.",
|
"description": "A hovering scout drone that skims ore off the surface.",
|
||||||
"image": "shovel.png",
|
"image": "shovel.png",
|
||||||
"price": 1200,
|
"price": 1200,
|
||||||
"claimAmount": 4,
|
"claimAmount": 8,
|
||||||
"completionTimeInSecs": 3,
|
"completionTimeInSecs": 3, "reach": 5,
|
||||||
"upgrades": [
|
"tracks": {
|
||||||
{"tier": 1, "price": 80, "claimBoost": 0, "speed": 1.12},
|
"yield": {"name": "Twin rotors", "levels": [
|
||||||
{"tier": 2, "price": 100, "claimBoost": 0, "speed": 1.25},
|
{"value": 10, "price": 280},
|
||||||
{"tier": 3, "price": 80, "claimBoost": 0, "speed": 1.4, "parts": {"Dusk Alloy": 1}},
|
{"value": 12, "price": 280},
|
||||||
{"tier": 4, "price": 110, "claimBoost": 0, "speed": 1.6, "parts": {"Dusk Alloy": 1}},
|
{"value": 14, "price": 290},
|
||||||
{"tier": 5, "price": 120, "claimBoost": 0, "speed": 1.8, "parts": {"Dusk Alloy": 2}}
|
{"value": 16, "price": 280}
|
||||||
]
|
]},
|
||||||
|
"cycle": {"name": "Flight computer", "levels": [
|
||||||
|
{"value": 2.6, "price": 180},
|
||||||
|
{"value": 2.2, "price": 240},
|
||||||
|
{"value": 1.9, "price": 250},
|
||||||
|
{"value": 1.7, "price": 210},
|
||||||
|
{"value": 1.5, "price": 270}
|
||||||
|
]}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"name": "Percussion Drill PX-4",
|
"name": "Percussion Drill PX-4",
|
||||||
"description": "Hammers the crust a thousand times a second to crack open ore seams.",
|
"description": "Hammers the crust a thousand times a second to crack open ore seams.",
|
||||||
"image": "pickaxe.png",
|
"image": "pickaxe.png",
|
||||||
"price": 5700,
|
"price": 8000,
|
||||||
"claimAmount": 20,
|
"claimAmount": 120,
|
||||||
"completionTimeInSecs": 6,
|
"completionTimeInSecs": 6, "reach": 5,
|
||||||
"upgrades": [
|
"tracks": {
|
||||||
{"tier": 1, "price": 400, "claimBoost": 0, "speed": 1.12},
|
"yield": {"name": "Wider chisel", "levels": [
|
||||||
{"tier": 2, "price": 470, "claimBoost": 0, "speed": 1.25},
|
{"value": 144, "price": 1520},
|
||||||
{"tier": 3, "price": 500, "claimBoost": 0, "speed": 1.4, "parts": {"Dusk Alloy": 2}},
|
{"value": 168, "price": 1520},
|
||||||
{"tier": 4, "price": 610, "claimBoost": 0, "speed": 1.6, "parts": {"Cryo Cell": 1}},
|
{"value": 192, "price": 1520},
|
||||||
{"tier": 5, "price": 780, "claimBoost": 0, "speed": 1.8, "parts": {"Plasma Weave": 1}}
|
{"value": 216, "price": 1520},
|
||||||
]
|
{"value": 240, "price": 1520}
|
||||||
|
]},
|
||||||
|
"cycle": {"name": "Pneumatic hammer", "levels": [
|
||||||
|
{"value": 5.2, "price": 1160},
|
||||||
|
{"value": 4.5, "price": 1360},
|
||||||
|
{"value": 3.9, "price": 1560},
|
||||||
|
{"value": 3.4, "price": 1720},
|
||||||
|
{"value": 3, "price": 1780}
|
||||||
|
]}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"name": "LunarLite L100",
|
"name": "LunarLite L100",
|
||||||
"description": "An entry-level rotary drill: point it down and the ore rolls in.",
|
"description": "An entry-level rotary drill: point it down and the ore rolls in.",
|
||||||
"image": "lunarlite.jpg",
|
"image": "lunarlite.jpg",
|
||||||
"price": 27000,
|
"price": 40200,
|
||||||
"claimAmount": 110,
|
"claimAmount": 900,
|
||||||
"completionTimeInSecs": 8,
|
"completionTimeInSecs": 8, "reach": 5,
|
||||||
"upgrades": [
|
"tracks": {
|
||||||
{"tier": 1, "price": 1700, "claimBoost": 0, "speed": 1.12},
|
"yield": {"name": "Wide-bore bit", "levels": [
|
||||||
{"tier": 2, "price": 4500, "claimBoost": 0, "speed": 1.25},
|
{"value": 1060, "price": 6900},
|
||||||
{"tier": 3, "price": 2900, "claimBoost": 0, "speed": 1.4, "parts": {"Cryo Cell": 1}},
|
{"value": 1270, "price": 8700},
|
||||||
{"tier": 4, "price": 3400, "claimBoost": 0, "speed": 1.6, "parts": {"Plasma Weave": 2}},
|
{"value": 1430, "price": 6900},
|
||||||
{"tier": 5, "price": 4300, "claimBoost": 0, "speed": 1.8, "parts": {"Plasma Weave": 3}}
|
{"value": 1640, "price": 8700},
|
||||||
]
|
{"value": 1800, "price": 6900}
|
||||||
|
]},
|
||||||
|
"cycle": {"name": "Spindle motor", "levels": [
|
||||||
|
{"value": 7, "price": 5400},
|
||||||
|
{"value": 6, "price": 7200},
|
||||||
|
{"value": 5.2, "price": 7800},
|
||||||
|
{"value": 4.6, "price": 7800},
|
||||||
|
{"value": 4, "price": 9900}
|
||||||
|
]}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"name": "LunarLite L300",
|
"name": "LunarLite L300",
|
||||||
"description": "The L100's laser-cutting big brother, built to burn through bedrock.",
|
"description": "The L100's laser-cutting big brother, built to burn through bedrock.",
|
||||||
"image": "lunarlite300.png",
|
"image": "lunarlite300.png",
|
||||||
"price": 290000,
|
"price": 645000,
|
||||||
"claimAmount": 1350,
|
"claimAmount": 20000,
|
||||||
"completionTimeInSecs": 10,
|
"completionTimeInSecs": 10,
|
||||||
"reach": 2,
|
"reach": 5,
|
||||||
"upgrades": [
|
"tracks": {
|
||||||
{"tier": 1, "price": 37000, "claimBoost": 0, "speed": 1.12},
|
"yield": {"name": "Wider beam", "levels": [
|
||||||
{"tier": 2, "price": 23000, "claimBoost": 0, "speed": 1.25},
|
{"value": 23700, "price": 115000},
|
||||||
{"tier": 3, "price": 28000, "claimBoost": 0, "speed": 1.4, "parts": {"Cryo Cell": 2}},
|
{"value": 28200, "price": 135000},
|
||||||
{"tier": 4, "price": 34000, "claimBoost": 0, "speed": 1.6, "parts": {"Plasma Weave": 3}},
|
{"value": 31800, "price": 115000},
|
||||||
{"tier": 5, "price": 42000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 1}}
|
{"value": 36300, "price": 135000},
|
||||||
]
|
{"value": 40000, "price": 115000}
|
||||||
|
]},
|
||||||
|
"cycle": {"name": "Laser capacitors", "levels": [
|
||||||
|
{"value": 8.7, "price": 90000},
|
||||||
|
{"value": 7.5, "price": 115000},
|
||||||
|
{"value": 6.5, "price": 125000},
|
||||||
|
{"value": 5.7, "price": 130000},
|
||||||
|
{"value": 5, "price": 150000}
|
||||||
|
]}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"name": "Lunar Regolith Extractor LRE-3000",
|
"name": "Lunar Regolith Extractor LRE-3000",
|
||||||
"description": "An auger rig that chews through regolith by the ton and keeps every grain worth selling.",
|
"description": "An auger rig that chews through regolith by the ton and keeps every grain worth selling.",
|
||||||
"image": "regolith.jpg",
|
"image": "regolith.jpg",
|
||||||
"price": 3200000,
|
"price": 9440000,
|
||||||
"claimAmount": 17000,
|
"claimAmount": 384000,
|
||||||
"completionTimeInSecs": 12,
|
"completionTimeInSecs": 12,
|
||||||
"reach": 3,
|
"reach": 5,
|
||||||
"upgrades": [
|
"tracks": {
|
||||||
{"tier": 1, "price": 380000, "claimBoost": 0, "speed": 1.12},
|
"yield": {"name": "Fine sieves", "levels": [
|
||||||
{"tier": 2, "price": 230000, "claimBoost": 0, "speed": 1.25},
|
{"value": 452000, "price": 1600000},
|
||||||
{"tier": 3, "price": 280000, "claimBoost": 0, "speed": 1.4, "parts": {"Plasma Weave": 3}},
|
{"value": 542000, "price": 2080000},
|
||||||
{"tier": 4, "price": 740000, "claimBoost": 0, "speed": 1.6, "parts": {"Void Core": 1}},
|
{"value": 610000, "price": 1600000},
|
||||||
{"tier": 5, "price": 510000, "claimBoost": 0, "speed": 1.8, "parts": {"Stellar Lattice": 1}}
|
{"value": 700000, "price": 2080000},
|
||||||
]
|
{"value": 768000, "price": 1600000}
|
||||||
|
]},
|
||||||
|
"cycle": {"name": "Auger gearbox", "levels": [
|
||||||
|
{"value": 10.4, "price": 1360000},
|
||||||
|
{"value": 9, "price": 1600000},
|
||||||
|
{"value": 7.8, "price": 1840000},
|
||||||
|
{"value": 6.8, "price": 2000000},
|
||||||
|
{"value": 6, "price": 2080000}
|
||||||
|
]}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"name": "Electrostatic Dust Excavator EDE-700",
|
"name": "Electrostatic Dust Excavator EDE-700",
|
||||||
"description": "Charged plates pull ore-rich dust straight out of the ground, no digging needed.",
|
"description": "Charged plates pull ore-rich dust straight out of the ground, no digging needed.",
|
||||||
"image": "dustexcavator.png",
|
"image": "dustexcavator.png",
|
||||||
"price": 7400000,
|
"price": 17000000,
|
||||||
"claimAmount": 36000,
|
"claimAmount": 880000,
|
||||||
"completionTimeInSecs": 14,
|
"completionTimeInSecs": 14,
|
||||||
"reach": 5,
|
"reach": 5,
|
||||||
"upgrades": [
|
"tracks": {
|
||||||
{"tier": 1, "price": 470000, "claimBoost": 0, "speed": 1.12},
|
"yield": {"name": "Larger plates", "levels": [
|
||||||
{"tier": 2, "price": 1200000, "claimBoost": 0, "speed": 1.25},
|
{"value": 1050000, "price": 3120000},
|
||||||
{"tier": 3, "price": 820000, "claimBoost": 0, "speed": 1.4, "parts": {"Void Core": 1}},
|
{"value": 1220000, "price": 3120000},
|
||||||
{"tier": 4, "price": 1000000, "claimBoost": 0, "speed": 1.6, "parts": {"Stellar Lattice": 1}},
|
{"value": 1420000, "price": 3600000},
|
||||||
{"tier": 5, "price": 1300000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
|
{"value": 1590000, "price": 3120000},
|
||||||
]
|
{"value": 1760000, "price": 3120000}
|
||||||
|
]},
|
||||||
|
"cycle": {"name": "Charge coils", "levels": [
|
||||||
|
{"value": 12.2, "price": 2400000},
|
||||||
|
{"value": 10.5, "price": 2960000},
|
||||||
|
{"value": 9.1, "price": 3280000},
|
||||||
|
{"value": 8, "price": 3440000},
|
||||||
|
{"value": 7, "price": 4000000}
|
||||||
|
]}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"name": "Lunar Core Extractor LCE-360",
|
"name": "Lunar Core Extractor LCE-360",
|
||||||
"description": "A deep-core rig that bores to the mantle and hauls up what's been buried for eons.",
|
"description": "A deep-core rig that bores to the mantle and hauls up what's been buried for eons.",
|
||||||
"image": "lunarcoreextractor.png",
|
"image": "lunarcoreextractor.png",
|
||||||
"price": 11000000,
|
"price": 24800000,
|
||||||
"claimAmount": 47000,
|
"claimAmount": 1600000,
|
||||||
"completionTimeInSecs": 16,
|
"completionTimeInSecs": 16,
|
||||||
"reach": 5,
|
"reach": 5,
|
||||||
"upgrades": [
|
"tracks": {
|
||||||
{"tier": 1, "price": 840000, "claimBoost": 0, "speed": 1.12},
|
"yield": {"name": "Core barrel", "levels": [
|
||||||
{"tier": 2, "price": 1000000, "claimBoost": 0, "speed": 1.25},
|
{"value": 1910000, "price": 4480000},
|
||||||
{"tier": 3, "price": 1300000, "claimBoost": 0, "speed": 1.4, "parts": {"Void Core": 2}},
|
{"value": 2250000, "price": 5040000},
|
||||||
{"tier": 4, "price": 1600000, "claimBoost": 0, "speed": 1.6, "parts": {"Stellar Lattice": 2}},
|
{"value": 2550000, "price": 4480000},
|
||||||
{"tier": 5, "price": 2200000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
|
{"value": 2890000, "price": 5040000},
|
||||||
]
|
{"value": 3200000, "price": 4480000}
|
||||||
|
]},
|
||||||
|
"cycle": {"name": "Drill string", "levels": [
|
||||||
|
{"value": 14, "price": 3360000},
|
||||||
|
{"value": 12, "price": 4480000},
|
||||||
|
{"value": 10.4, "price": 4800000},
|
||||||
|
{"value": 9.1, "price": 5200000},
|
||||||
|
{"value": 8, "price": 5680000}
|
||||||
|
]}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"moons": [
|
"moons": [
|
||||||
@@ -195,49 +259,36 @@
|
|||||||
},
|
},
|
||||||
"refinery": {
|
"refinery": {
|
||||||
"slotPrices": [1500, 4000, 10000, 25000, 60000, 150000, 400000, 1000000, 2500000],
|
"slotPrices": [1500, 4000, 10000, 25000, 60000, 150000, 400000, 1000000, 2500000],
|
||||||
"maxBatch": 25,
|
"maxBatch": 100,
|
||||||
"batchUpgrades": [
|
"batchUpgrades": [
|
||||||
{ "maxBatch": 50, "price": 6000 },
|
{ "maxBatch": 250, "price": 6000 },
|
||||||
{ "maxBatch": 100, "price": 25000 },
|
{ "maxBatch": 500, "price": 30000 },
|
||||||
{ "maxBatch": 250, "price": 120000 },
|
{ "maxBatch": 1000, "price": 150000 },
|
||||||
{ "maxBatch": 500, "price": 600000 },
|
{ "maxBatch": 2500, "price": 600000 },
|
||||||
{ "maxBatch": 1000, "price": 3000000 },
|
{ "maxBatch": 5000, "price": 3000000 },
|
||||||
{ "maxBatch": 2500, "price": 15000000 }
|
{ "maxBatch": 10000, "price": 15000000 }
|
||||||
],
|
],
|
||||||
"bulkLog": 1.83,
|
"bulkLog": 1.0,
|
||||||
"unlockGrade": "C",
|
"unlockGrade": "C",
|
||||||
"items": [
|
"items": [
|
||||||
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 40, "seconds": 1,
|
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 28, "seconds": 1,
|
||||||
"inputs": {"Sollux": 2, "Shadowstone": 2},
|
"inputs": {"Sollux": 2, "Shadowstone": 2},
|
||||||
"description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."},
|
"description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."},
|
||||||
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 113, "seconds": 2,
|
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 97, "seconds": 2,
|
||||||
"inputs": {"Azurium": 3, "Novafor": 3},
|
"inputs": {"Azurium": 3, "Novafor": 3},
|
||||||
"description": "Azurium coolant sealed around a Novafor core keeps bits from melting at depth."},
|
"description": "Azurium coolant sealed around a Novafor core keeps bits from melting at depth."},
|
||||||
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 157, "seconds": 3,
|
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 134, "seconds": 2,
|
||||||
"inputs": {"Nebulance": 4, "Sollux": 2},
|
"inputs": {"Nebulance": 4, "Sollux": 2},
|
||||||
"description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."},
|
"description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."},
|
||||||
{"name": "Void Core", "tier": 2, "color": "#c4b5fd", "price": 580, "seconds": 5,
|
{"name": "Void Core", "tier": 2, "unlockGrade": "B", "color": "#c4b5fd", "price": 460, "seconds": 3,
|
||||||
"inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5},
|
"inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5},
|
||||||
"description": "A cold, dense power core that drives the heaviest rigs for weeks."},
|
"description": "A cold, dense power core that drives the heaviest rigs for weeks."},
|
||||||
{"name": "Stellar Lattice", "tier": 2, "color": "#fde047", "price": 630, "seconds": 6,
|
{"name": "Stellar Lattice", "tier": 2, "unlockGrade": "B", "color": "#fde047", "price": 470, "seconds": 3,
|
||||||
"inputs": {"Plasma Weave": 2, "Cryo Cell": 1, "Shadowstone": 4},
|
"inputs": {"Plasma Weave": 2, "Cryo Cell": 1, "Shadowstone": 4},
|
||||||
"description": "A self-aligning crystal frame that lets deep-core extractors bore without drift."}
|
"description": "A self-aligning crystal frame that lets deep-core extractors bore without drift."}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"bonusCaps": {"speed": 1.6, "yield": 1.6, "sell": 1.15, "refine": 1.5},
|
"bonusCaps": {"speed": 1.6, "yield": 1.6, "sell": 1.15, "refine": 1.5},
|
||||||
"attachments": {
|
|
||||||
"slotsPerTool": 2,
|
|
||||||
"items": [
|
|
||||||
{"name": "Turbo Core", "image": "turbo-core.png", "price": 900, "speed": 1.2,
|
|
||||||
"description": "An overdriven motor core. Runs hot, cycles fast."},
|
|
||||||
{"name": "Diamond Bit", "image": "diamond-bit.png", "price": 950, "yield": 1.15,
|
|
||||||
"description": "A synthetic-diamond cutting face that keeps more ore in every bite."},
|
|
||||||
{"name": "Cryo Cooler", "image": "cryo-cooler.png", "price": 85000, "speed": 1.35,
|
|
||||||
"description": "Wraps the spindle in coolant so the drill never stops to cool down."},
|
|
||||||
{"name": "Seam Scanner", "image": "seam-scanner.png", "price": 90000, "yield": 1.3,
|
|
||||||
"description": "Ground radar that steers the head into the richest part of the seam."}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"advisors": {
|
"advisors": {
|
||||||
"seatPrices": [20000, 400000, 8000000],
|
"seatPrices": [20000, 400000, 8000000],
|
||||||
"items": [
|
"items": [
|
||||||
|
|||||||
+57
-20
@@ -12,6 +12,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -295,8 +296,9 @@ func watchOverride(ctx context.Context, db *pgxpool.Pool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// saveOverride records a new override (by the given admin), re-syncs the store items and
|
// saveOverride records a new override (by the given admin), re-syncs the store items and
|
||||||
// puts it in effect.
|
// puts it in effect. With since >= 0 it refuses when the newest saved version is not since
|
||||||
func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note, userID string) (int64, error) {
|
// (0: none), so a save from a page loaded before another save doesn't undo that save.
|
||||||
|
func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note, userID string, since int64) (int64, error) {
|
||||||
c, err := applyOverride(ov)
|
c, err := applyOverride(ov)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, gameErr("%s", err.Error())
|
return 0, gameErr("%s", err.Error())
|
||||||
@@ -307,6 +309,19 @@ func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note
|
|||||||
}
|
}
|
||||||
var id int64
|
var id int64
|
||||||
err = inTx(ctx, db, func(tx pgx.Tx) error {
|
err = inTx(ctx, db, func(tx pgx.Tx) error {
|
||||||
|
if since >= 0 {
|
||||||
|
// The lock makes two saves from the same version take turns.
|
||||||
|
if _, err := tx.Exec(ctx, "lock table config_override in share row exclusive mode"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var latest int64
|
||||||
|
if err := tx.QueryRow(ctx, "select coalesce(max(id), 0) from config_override").Scan(&latest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if latest != since {
|
||||||
|
return gameErr("Version #%d was saved after this page loaded. Reload the page and make your changes again.", latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
err := tx.QueryRow(ctx,
|
err := tx.QueryRow(ctx,
|
||||||
`insert into config_override(data, note, created_by)
|
`insert into config_override(data, note, created_by)
|
||||||
values ($1, $2, (select id from users where id::text = $3)) returning id`,
|
values ($1, $2, (select id from users where id::text = $3)) returning id`,
|
||||||
@@ -490,10 +505,6 @@ func (s *server) economyRoutes(mux *http.ServeMux) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.isAdmin(s.currentUser(r)) {
|
|
||||||
s.forbidden(w, "economy", &EconomyPage{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
// Pick up a save made by another instance before showing the editor.
|
// Pick up a save made by another instance before showing the editor.
|
||||||
if err := loadOverride(ctx, s.db); err != nil {
|
if err := loadOverride(ctx, s.db); err != nil {
|
||||||
@@ -505,18 +516,33 @@ func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
|
|||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The newest saved version (not always the one in effect: an invalid one is skipped);
|
||||||
|
// a save from this page is refused once there is a newer one.
|
||||||
|
var latest int64
|
||||||
|
if len(history) > 0 {
|
||||||
|
latest = history[0].ID
|
||||||
|
}
|
||||||
base := baseConfig()
|
base := baseConfig()
|
||||||
c := cfg()
|
c := cfg()
|
||||||
quotes := marketQuotes(c, time.Now())
|
quotes := marketQuotes(c, time.Now())
|
||||||
|
data := map[string]any{
|
||||||
|
"saved": configDoc(c),
|
||||||
|
"base": configDoc(&base),
|
||||||
|
"version": overrideVersion.Load(),
|
||||||
|
"latest": latest,
|
||||||
|
"market": quotes,
|
||||||
|
"colors": resourceColors,
|
||||||
|
}
|
||||||
|
// Dev only: ?draft=<JSON merge patch> opens the editor with that draft (screenshots).
|
||||||
|
if raw := r.FormValue("draft"); s.dev && raw != "" {
|
||||||
|
var patch any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &patch); err == nil {
|
||||||
|
data["draft"] = mergePatch(configDoc(c), patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
s.render(w, "economy", &EconomyPage{
|
s.render(w, "economy", &EconomyPage{
|
||||||
Allowed: true,
|
Allowed: true,
|
||||||
Data: map[string]any{
|
Data: data,
|
||||||
"saved": configDoc(c),
|
|
||||||
"base": configDoc(&base),
|
|
||||||
"version": overrideVersion.Load(),
|
|
||||||
"market": quotes,
|
|
||||||
"colors": resourceColors,
|
|
||||||
},
|
|
||||||
Quotes: quotes,
|
Quotes: quotes,
|
||||||
History: history,
|
History: history,
|
||||||
Version: overrideVersion.Load(),
|
Version: overrideVersion.Load(),
|
||||||
@@ -529,11 +555,6 @@ func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
|
|||||||
// goes back to game.json. Save, revert and reset answer JSON: {"version", "message"} or
|
// goes back to game.json. Save, revert and reset answer JSON: {"version", "message"} or
|
||||||
// {"errors": [...]}.
|
// {"errors": [...]}.
|
||||||
func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := s.currentUser(r)
|
|
||||||
if !s.isAdmin(userID) {
|
|
||||||
http.Error(w, "Missing permission", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
reply := func(status int, v any) {
|
reply := func(status int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -542,6 +563,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var ov ConfigOverride
|
var ov ConfigOverride
|
||||||
var note string
|
var note string
|
||||||
|
since := int64(-1) // save: the version the page loaded with
|
||||||
switch r.PathValue("action") {
|
switch r.PathValue("action") {
|
||||||
case "analyze":
|
case "analyze":
|
||||||
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
|
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
|
||||||
@@ -549,7 +571,18 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, "economy-analysis", analyze(&draft, []string{err.Error()}, 0))
|
s.render(w, "economy-analysis", analyze(&draft, []string{err.Error()}, 0))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, "economy-analysis", analyze(&draft, nil, diffConfig(cfg(), &draft).ChangeCount()))
|
n := diffConfig(cfg(), &draft).ChangeCount()
|
||||||
|
a := analyze(&draft, nil, n)
|
||||||
|
a.Progress = progression(cfg(), &draft, n == 0)
|
||||||
|
s.render(w, "economy-analysis", a)
|
||||||
|
return
|
||||||
|
case "sim":
|
||||||
|
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, `<p class="eco-bad">%s</p>`, template.HTMLEscapeString(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, "economy-sim", simTimeline(cfg(), &draft, diffConfig(cfg(), &draft).ChangeCount() == 0))
|
||||||
return
|
return
|
||||||
case "save":
|
case "save":
|
||||||
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
|
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
|
||||||
@@ -557,6 +590,10 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
|
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if since, err = strconv.ParseInt(r.PostFormValue("since"), 10, 64); err != nil || since < 0 {
|
||||||
|
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"Reload the page: the save didn't say which version it started from"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
if diffConfig(cfg(), &draft).ChangeCount() == 0 {
|
if diffConfig(cfg(), &draft).ChangeCount() == 0 {
|
||||||
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"No changes to save"}})
|
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"No changes to save"}})
|
||||||
return
|
return
|
||||||
@@ -584,7 +621,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, err := saveOverride(ctx, s.db, ov, note, userID)
|
id, err := saveOverride(ctx, s.db, ov, note, "", since)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isGameError(err) {
|
if isGameError(err) {
|
||||||
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
|
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
|
||||||
|
|||||||
+894
-323
File diff suppressed because it is too large.
Load diff
+109
-84
@@ -4,7 +4,7 @@ package main
|
|||||||
// players are active: they claim (claim all with redeploy) as soon as a drill is ready (at
|
// players are active: they claim (claim all with redeploy) as soon as a drill is ready (at
|
||||||
// most every gsClaimGap), stake drills whose well ran dry, sell every half minute and buy, all within a
|
// most every gsClaimGap), stake drills whose well ran dry, sell every half minute and buy, all within a
|
||||||
// click budget (gsClicksPerSec). The "copies" player buys only drill copies (and moons); the
|
// click budget (gsClicksPerSec). The "copies" player buys only drill copies (and moons); the
|
||||||
// "boosts" player buys whatever adds the most ₥/minute per ₥ (upgrades, attachments,
|
// "boosts" player buys whatever adds the most ₥/minute per ₥ (a level on a yield or cycle track,
|
||||||
// advisors or copies). Both buy a moon when their drills are about to run dry. Every report
|
// advisors or copies). Both buy a moon when their drills are about to run dry. Every report
|
||||||
// row lists the best buy of each kind with what it adds per ₥ spent, in ₥/minute and in
|
// row lists the best buy of each kind with what it adds per ₥ spent, in ₥/minute and in
|
||||||
// ₥/click, and the summary compares the boosts with the copies.
|
// ₥/click, and the summary compares the boosts with the copies.
|
||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type gsPlayer struct {
|
type gsPlayer struct {
|
||||||
@@ -35,13 +34,15 @@ const (
|
|||||||
gsClickBurst = 4.0 // clicks a player can bank for a burst
|
gsClickBurst = 4.0 // clicks a player can bank for a burst
|
||||||
gsSellEvery = 30 // seconds between sell rounds (one click per resource)
|
gsSellEvery = 30 // seconds between sell rounds (one click per resource)
|
||||||
gsClaimGap = 3 // seconds a player waits at least between two claims
|
gsClaimGap = 3 // seconds a player waits at least between two claims
|
||||||
|
gsSampleEvery = 30 // seconds between timeline samples
|
||||||
|
gsIdleShow = 5 // shortest stretch with nothing to click the timeline shows
|
||||||
)
|
)
|
||||||
|
|
||||||
type gsTool struct {
|
type gsTool struct {
|
||||||
item, tier int
|
item int
|
||||||
mods []string
|
lv Levels
|
||||||
doneAt int // busy until then; -1 idle
|
doneAt int // busy until then; -1 idle
|
||||||
take []gsTake
|
take []gsTake
|
||||||
}
|
}
|
||||||
|
|
||||||
type gsTake struct {
|
type gsTake struct {
|
||||||
@@ -61,21 +62,20 @@ type gsMoon struct {
|
|||||||
free float64 // ore not yet taken or booked
|
free float64 // ore not yet taken or booked
|
||||||
}
|
}
|
||||||
|
|
||||||
// gsGroup is the drills of one kind (store item, tier, attachments); tool is one of them.
|
// gsGroup is the drills of one kind (store item, track levels); tool is one of them.
|
||||||
type gsGroup struct {
|
type gsGroup struct {
|
||||||
item, tier int
|
item int
|
||||||
mods []string
|
lv Levels
|
||||||
n, tool int
|
n, tool int
|
||||||
}
|
}
|
||||||
|
|
||||||
func gsKey(item, tier int, mods []string) string { return fmt.Sprint(item, tier, mods) }
|
|
||||||
|
|
||||||
// GSBuy is a candidate purchase: what it adds and what it costs.
|
// GSBuy is a candidate purchase: what it adds and what it costs.
|
||||||
type GSBuy struct {
|
type GSBuy struct {
|
||||||
Kind, Name string
|
Kind, Name string
|
||||||
Cost float64 // ₥
|
Cost float64 // ₥
|
||||||
Gain, Click float64 // ₥/hour added, ₥/click added
|
Gain, Click float64 // ₥/hour added, ₥/click added
|
||||||
tool int // upgrade or attachment: which tool; copy: which store item
|
tool int // upgrade: which tool; copy: which store item
|
||||||
|
track string // upgrade: yield or cycle
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b GSBuy) Payback() string {
|
func (b GSBuy) Payback() string {
|
||||||
@@ -118,6 +118,23 @@ type GSResult struct {
|
|||||||
BuyGapAt SimTime
|
BuyGapAt SimTime
|
||||||
BuyGapPreS SimTime // the same, before the first moon of the top grade
|
BuyGapPreS SimTime // the same, before the first moon of the top grade
|
||||||
IdleAt SimTime
|
IdleAt SimTime
|
||||||
|
// For the timeline chart (progression.go):
|
||||||
|
Samples []GSSample // every gsSampleEvery seconds
|
||||||
|
Buys []GSEvent // copies, upgrades and advisors (moons: FirstGrade)
|
||||||
|
Idles [][2]int // stretches of gsIdleShow seconds or more with nothing to click, [from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GSSample is the player's credits and total earnings at a time (seconds).
|
||||||
|
type GSSample struct {
|
||||||
|
At int
|
||||||
|
Credits, Earned float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GSEvent is a purchase at a time (seconds).
|
||||||
|
type GSEvent struct {
|
||||||
|
At int
|
||||||
|
Kind, Name string
|
||||||
|
Cost float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// GradeStat is the value of a grade's moons at base prices.
|
// GradeStat is the value of a grade's moons at base prices.
|
||||||
@@ -162,7 +179,7 @@ func gradeStats(c *GameConfig) []GradeStat {
|
|||||||
if st.Drill != "" {
|
if st.Drill != "" {
|
||||||
st.Drill += ", "
|
st.Drill += ", "
|
||||||
}
|
}
|
||||||
perWell := item.ClaimAmount / float64(item.ToolReach(0))
|
perWell := item.ClaimAmount / float64(item.ToolReach())
|
||||||
st.Drill += fmt.Sprintf("%s (%.1f claims, %ds)", toolCode(item.Name), g.MeanSupply()/perWell,
|
st.Drill += fmt.Sprintf("%s (%.1f claims, %ds)", toolCode(item.Name), g.MeanSupply()/perWell,
|
||||||
item.CompletionTimeInSecs)
|
item.CompletionTimeInSecs)
|
||||||
}
|
}
|
||||||
@@ -180,17 +197,17 @@ func gradeStats(c *GameConfig) []GradeStat {
|
|||||||
func gsEval(c *GameConfig, stats []GradeStat, caps func(int) ReachCaps, grade int, gs []gsGroup, adv []string) (inc, clk float64) {
|
func gsEval(c *GameConfig, stats []GradeStat, caps func(int) ReachCaps, grade int, gs []gsGroup, adv []string) (inc, clk float64) {
|
||||||
st := stats[grade]
|
st := stats[grade]
|
||||||
stakes := 0.0
|
stakes := 0.0
|
||||||
cycles := map[int]bool{}
|
cycles := map[float64]bool{}
|
||||||
|
b := c.bonusesFor(adv)
|
||||||
for _, x := range gs {
|
for _, x := range gs {
|
||||||
if x.n == 0 {
|
if x.n == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
it := &c.StoreItems[x.item]
|
it := &c.StoreItems[x.item]
|
||||||
b := c.bonusesFor(adv, x.mods)
|
secs, y := drillStats(it.ToolSecs(x.lv), it.ToolYield(x.lv), b, 1, 1)
|
||||||
secs, y := drillStats(it.ToolSecs(x.tier), it.ToolYield(x.tier), b, 1, 1)
|
reach := it.ToolReach()
|
||||||
reach := it.ToolReach(x.tier)
|
|
||||||
cycles[max(secs, gsClaimGap)] = true
|
cycles[max(secs, gsClaimGap)] = true
|
||||||
p := float64(max(secs, gsClaimGap)) + 0.5 // waits half a tick on average
|
p := max(secs, gsClaimGap) + 0.5 // waits half a tick on average
|
||||||
n := float64(x.n)
|
n := float64(x.n)
|
||||||
inc += n * caps(grade).Yield(y, reach) * st.Unit * b.Sell * 3600 / p
|
inc += n * caps(grade).Yield(y, reach) * st.Unit * b.Sell * 3600 / p
|
||||||
per := st.Units / max(1, st.Wells) / y
|
per := st.Units / max(1, st.Wells) / y
|
||||||
@@ -201,7 +218,7 @@ func gsEval(c *GameConfig, stats []GradeStat, caps func(int) ReachCaps, grade in
|
|||||||
}
|
}
|
||||||
claims := 0.0
|
claims := 0.0
|
||||||
for secs := range cycles {
|
for secs := range cycles {
|
||||||
claims += 3600 / float64(secs)
|
claims += 3600 / secs
|
||||||
}
|
}
|
||||||
clk = min(claims, 3600/gsClaimGap) + stakes + 3600/gsSellEvery*min(5, math.Round(st.Wells))
|
clk = min(claims, 3600/gsClaimGap) + stakes + 3600/gsSellEvery*min(5, math.Round(st.Wells))
|
||||||
if budget := 3600 * gsClicksPerSec; clk > budget {
|
if budget := 3600 * gsClicksPerSec; clk > budget {
|
||||||
@@ -224,7 +241,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
var advisors []string
|
var advisors []string
|
||||||
seats := 1
|
seats := 1
|
||||||
refinery := c.Refinery.UnlockGrade == ""
|
refinery := c.Refinery.UnlockGrade == ""
|
||||||
grade := 0 // of the last moon bought
|
grade := 0 // the dearest moon bought: purchases are valued on it, and its price is kept
|
||||||
pressure := map[string]float64{}
|
pressure := map[string]float64{}
|
||||||
stock := map[string]float64{}
|
stock := map[string]float64{}
|
||||||
var resources []string
|
var resources []string
|
||||||
@@ -250,30 +267,29 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
idx := map[string]int{}
|
idx := map[string]int{}
|
||||||
var out []gsGroup
|
var out []gsGroup
|
||||||
for j, t := range tools {
|
for j, t := range tools {
|
||||||
k := gsKey(t.item, t.tier, t.mods)
|
k := fmt.Sprint(t.item, t.lv)
|
||||||
if i, ok := idx[k]; ok {
|
if i, ok := idx[k]; ok {
|
||||||
out[i].n++
|
out[i].n++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
idx[k] = len(out)
|
idx[k] = len(out)
|
||||||
out = append(out, gsGroup{t.item, t.tier, t.mods, 1, j})
|
out = append(out, gsGroup{t.item, t.lv, 1, j})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
// with is gs with one drill of group k (none if k < 0) turned into (item, tier, mods).
|
// with is gs with one drill of group k (none if k < 0) turned into (item, lv).
|
||||||
with := func(gs []gsGroup, k, item, tier int, mods []string) []gsGroup {
|
with := func(gs []gsGroup, k, item int, lv Levels) []gsGroup {
|
||||||
out := slices.Clone(gs)
|
out := slices.Clone(gs)
|
||||||
if k >= 0 {
|
if k >= 0 {
|
||||||
out[k].n--
|
out[k].n--
|
||||||
}
|
}
|
||||||
key := gsKey(item, tier, mods)
|
|
||||||
for i := range out {
|
for i := range out {
|
||||||
if gsKey(out[i].item, out[i].tier, out[i].mods) == key {
|
if out[i].item == item && out[i].lv == lv {
|
||||||
out[i].n++
|
out[i].n++
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return append(out, gsGroup{item, tier, mods, 1, -1})
|
return append(out, gsGroup{item, lv, 1, -1})
|
||||||
}
|
}
|
||||||
eval := func(gs []gsGroup, adv []string) (float64, float64) { return gsEval(c, stats, caps, grade, gs, adv) }
|
eval := func(gs []gsGroup, adv []string) (float64, float64) { return gsEval(c, stats, caps, grade, gs, adv) }
|
||||||
partsCost := func(parts map[string]float64) (float64, bool) {
|
partsCost := func(parts map[string]float64) (float64, bool) {
|
||||||
@@ -294,25 +310,20 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
var out []GSBuy
|
var out []GSBuy
|
||||||
if len(tools) < gsMaxTools {
|
if len(tools) < gsMaxTools {
|
||||||
for i, it := range c.StoreItems {
|
for i, it := range c.StoreItems {
|
||||||
out = append(out, buy("copy", toolCode(it.Name), it.Price, i, with(gs, -1, i, 0, nil), advisors))
|
out = append(out, buy("copy", toolCode(it.Name), it.Price, i, with(gs, -1, i, Levels{}), advisors))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for k, x := range gs {
|
for k, x := range gs {
|
||||||
it := &c.StoreItems[x.item]
|
it := &c.StoreItems[x.item]
|
||||||
if u := it.NextUpgrade(x.tier); u != nil {
|
for _, kind := range trackKinds {
|
||||||
if pc, ok := partsCost(u.Parts); ok {
|
n := x.lv.Of(kind) + 1
|
||||||
out = append(out, buy("upgrade", fmt.Sprintf("%s t%d", toolCode(it.Name), u.Tier), u.Price+pc,
|
if u := it.TrackLevel(kind, n); u != nil {
|
||||||
x.tool, with(gs, k, x.item, u.Tier, x.mods), advisors))
|
if pc, ok := partsCost(u.Parts); ok {
|
||||||
}
|
b := buy("upgrade", fmt.Sprintf("%s %s%d", toolCode(it.Name), kind[:1], n), u.Price+pc,
|
||||||
}
|
x.tool, with(gs, k, x.item, x.lv.With(kind, n)), advisors)
|
||||||
if len(x.mods) < c.Attachments.SlotsPerTool {
|
b.track = kind
|
||||||
for _, m := range c.Attachments.Items {
|
out = append(out, b)
|
||||||
if slices.Contains(x.mods, m.Name) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
mods := append(slices.Clone(x.mods), m.Name)
|
|
||||||
out = append(out, buy("attachment", m.Name+" on "+toolCode(it.Name), m.Price, x.tool,
|
|
||||||
with(gs, k, x.item, x.tier, mods), advisors))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,7 +358,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
sum := 0.0
|
sum := 0.0
|
||||||
for _, t := range tools {
|
for _, t := range tools {
|
||||||
it := &c.StoreItems[t.item]
|
it := &c.StoreItems[t.item]
|
||||||
_, y := drillStats(it.ToolSecs(t.tier), it.ToolYield(t.tier), c.bonusesFor(advisors, t.mods), 1, 1)
|
_, y := drillStats(it.ToolSecs(t.lv), it.ToolYield(t.lv), c.bonusesFor(advisors), 1, 1)
|
||||||
sum += 2 * y
|
sum += 2 * y
|
||||||
}
|
}
|
||||||
return sum
|
return sum
|
||||||
@@ -362,6 +373,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
if gap := SimTime(t - lastClick); gap > res.Idle {
|
if gap := SimTime(t - lastClick); gap > res.Idle {
|
||||||
res.Idle, res.IdleAt = gap, SimTime(lastClick)
|
res.Idle, res.IdleAt = gap, SimTime(lastClick)
|
||||||
}
|
}
|
||||||
|
if t-lastClick >= gsIdleShow {
|
||||||
|
res.Idles = append(res.Idles, [2]int{lastClick, t})
|
||||||
|
}
|
||||||
lastClick = t
|
lastClick = t
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -380,7 +394,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
freeOre += m.free
|
freeOre += m.free
|
||||||
open = append(open, len(moons))
|
open = append(open, len(moons))
|
||||||
moons = append(moons, m)
|
moons = append(moons, m)
|
||||||
grade = g
|
grade = max(grade, g)
|
||||||
if _, ok := res.FirstGrade[gr.Grade]; !ok {
|
if _, ok := res.FirstGrade[gr.Grade]; !ok {
|
||||||
res.FirstGrade[gr.Grade] = SimTime(t)
|
res.FirstGrade[gr.Grade] = SimTime(t)
|
||||||
res.GradeClicks[gr.Grade] = clicks
|
res.GradeClicks[gr.Grade] = clicks
|
||||||
@@ -394,7 +408,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
// book stakes a tool on moon mi: on well only (a one-well redeploy), else its fullest wells.
|
// book stakes a tool on moon mi: on well only (a one-well redeploy), else its fullest wells.
|
||||||
book := func(t int, tl *gsTool, mi, only int) bool {
|
book := func(t int, tl *gsTool, mi, only int) bool {
|
||||||
it := &c.StoreItems[tl.item]
|
it := &c.StoreItems[tl.item]
|
||||||
secs, y := drillStats(it.ToolSecs(tl.tier), it.ToolYield(tl.tier), c.bonusesFor(advisors, tl.mods), 1, 1)
|
secs, y := drillStats(it.ToolSecs(tl.lv), it.ToolYield(tl.lv), c.bonusesFor(advisors), 1, 1)
|
||||||
m := moons[mi]
|
m := moons[mi]
|
||||||
var order []int
|
var order []int
|
||||||
if only >= 0 {
|
if only >= 0 {
|
||||||
@@ -406,9 +420,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
slices.SortFunc(order, func(a, b int) int {
|
slices.SortFunc(order, func(a, b int) int {
|
||||||
return cmpDesc(m.wells[a].left-m.wells[a].booked, m.wells[b].left-m.wells[b].booked)
|
return cmpDesc(m.wells[a].left-m.wells[a].booked, m.wells[b].left-m.wells[b].booked)
|
||||||
})
|
})
|
||||||
order = order[:min(it.ToolReach(tl.tier), len(order))]
|
order = order[:min(it.ToolReach(), len(order))]
|
||||||
}
|
}
|
||||||
share := y / float64(it.ToolReach(tl.tier))
|
share := y / float64(it.ToolReach())
|
||||||
tl.take = tl.take[:0]
|
tl.take = tl.take[:0]
|
||||||
for _, wi := range order {
|
for _, wi := range order {
|
||||||
w := &m.wells[wi]
|
w := &m.wells[wi]
|
||||||
@@ -423,7 +437,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
if len(tl.take) == 0 {
|
if len(tl.take) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
tl.doneAt = t + secs
|
tl.doneAt = t + int(math.Ceil(secs-1e-9))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// deploy stakes an idle tool on the moon with the most free ore.
|
// deploy stakes an idle tool on the moon with the most free ore.
|
||||||
@@ -471,7 +485,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
// Redeploy: the same well for a one-well drill, the same moon for the others.
|
// Redeploy: the same well for a one-well drill, the same moon for the others.
|
||||||
if len(prev) > 0 {
|
if len(prev) > 0 {
|
||||||
only := -1
|
only := -1
|
||||||
if c.StoreItems[tl.item].ToolReach(tl.tier) == 1 {
|
if c.StoreItems[tl.item].ToolReach() == 1 {
|
||||||
only = prev[0].well
|
only = prev[0].well
|
||||||
}
|
}
|
||||||
book(t, tl, prev[0].moon, only)
|
book(t, tl, prev[0].moon, only)
|
||||||
@@ -489,7 +503,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
pressure[name] = c.MarketPressure.Decay(pressure[name], 1)
|
pressure[name] = c.MarketPressure.Decay(pressure[name], 1)
|
||||||
}
|
}
|
||||||
if t-lastSell >= gsSellEvery {
|
if t-lastSell >= gsSellEvery {
|
||||||
sell := c.bonusesFor(advisors, nil).Sell
|
sell := c.bonusesFor(advisors).Sell
|
||||||
tick := int64(t / 300)
|
tick := int64(t / 300)
|
||||||
left := false
|
left := false
|
||||||
for _, name := range resources {
|
for _, name := range resources {
|
||||||
@@ -515,6 +529,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
lastSell = t
|
lastSell = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if t%gsSampleEvery == 0 {
|
||||||
|
res.Samples = append(res.Samples, GSSample{t, credits, earned})
|
||||||
|
}
|
||||||
report := next < len(gsReportAt) && t >= gsReportAt[next]*60
|
report := next < len(gsReportAt) && t >= gsReportAt[next]*60
|
||||||
if !wantBuy && !report && freeOre >= need() {
|
if !wantBuy && !report && freeOre >= need() {
|
||||||
continue
|
continue
|
||||||
@@ -556,7 +573,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
row := GSRow{At: SimTime(t), Credits: credits, Tools: len(tools), Grade: c.MoonGrades[grade].Grade,
|
row := GSRow{At: SimTime(t), Credits: credits, Tools: len(tools), Grade: c.MoonGrades[grade].Grade,
|
||||||
Income: (earned - lastEarned) * 3600 / dt, PerClick: (earned - lastEarned) / float64(max(1, clicks-lastClicks)),
|
Income: (earned - lastEarned) * 3600 / dt, PerClick: (earned - lastEarned) / float64(max(1, clicks-lastClicks)),
|
||||||
ClickMin: float64(clicks-lastClicks) * 60 / dt, Best: map[string]GSBuy{}, BestKind: best("", pl.All).Kind}
|
ClickMin: float64(clicks-lastClicks) * 60 / dt, Best: map[string]GSBuy{}, BestKind: best("", pl.All).Kind}
|
||||||
for _, k := range []string{"copy", "upgrade", "attachment", "advisor"} {
|
for _, k := range []string{"copy", "upgrade", "advisor"} {
|
||||||
row.Best[k] = best(k, true)
|
row.Best[k] = best(k, true)
|
||||||
}
|
}
|
||||||
res.Rows = append(res.Rows, row)
|
res.Rows = append(res.Rows, row)
|
||||||
@@ -566,17 +583,18 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
wantBuy = false
|
wantBuy = false
|
||||||
for range 50 {
|
for range 50 {
|
||||||
b := best("", pl.All)
|
b := best("", pl.All)
|
||||||
// Keep enough for the next moon, unless the drills have plenty of ore.
|
// Keep enough for the next moon, unless the drills have plenty of ore: the dearest
|
||||||
|
// grade bought (a grade picked from credits would fall with every purchase).
|
||||||
reserve := 0.0
|
reserve := 0.0
|
||||||
if freeOre < 20*need() {
|
if freeOre < 20*need() {
|
||||||
reserve = c.MoonGrades[nextGrade(income)].Price
|
reserve = c.MoonGrades[grade].Price
|
||||||
}
|
}
|
||||||
if b.Kind == "" || credits-b.Cost < reserve {
|
if b.Kind == "" || credits-b.Cost < reserve {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
cost := 1.0
|
cost := 1.0
|
||||||
if b.Kind == "attachment" || (b.Kind == "advisor" && len(advisors) >= seats) {
|
if b.Kind == "advisor" && len(advisors) >= seats {
|
||||||
cost = 2 // buy and equip, buy a seat and hire
|
cost = 2 // buy a seat and hire
|
||||||
}
|
}
|
||||||
if bank < cost {
|
if bank < cost {
|
||||||
wantBuy = true
|
wantBuy = true
|
||||||
@@ -587,6 +605,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
}
|
}
|
||||||
credits -= b.Cost
|
credits -= b.Cost
|
||||||
res.Bought[b.Kind]++
|
res.Bought[b.Kind]++
|
||||||
|
res.Buys = append(res.Buys, GSEvent{t, b.Kind, b.Name, b.Cost})
|
||||||
res.Spent[b.Kind] += b.Cost
|
res.Spent[b.Kind] += b.Cost
|
||||||
res.GainMin[b.Kind] += b.Gain / 60
|
res.GainMin[b.Kind] += b.Gain / 60
|
||||||
res.GainClick[b.Kind] += b.Click
|
res.GainClick[b.Kind] += b.Click
|
||||||
@@ -594,10 +613,8 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
|
|||||||
case "copy":
|
case "copy":
|
||||||
tools = append(tools, gsTool{item: b.tool, doneAt: -1})
|
tools = append(tools, gsTool{item: b.tool, doneAt: -1})
|
||||||
case "upgrade":
|
case "upgrade":
|
||||||
tools[b.tool].tier++
|
lv := tools[b.tool].lv
|
||||||
case "attachment":
|
tools[b.tool].lv = lv.With(b.track, lv.Of(b.track)+1)
|
||||||
name := b.Name[:strings.Index(b.Name, " on ")]
|
|
||||||
tools[b.tool].mods = append(slices.Clone(tools[b.tool].mods), name)
|
|
||||||
case "advisor":
|
case "advisor":
|
||||||
if len(advisors) >= seats {
|
if len(advisors) >= seats {
|
||||||
seats++
|
seats++
|
||||||
@@ -626,8 +643,8 @@ func printGradeSim(c *GameConfig) {
|
|||||||
for _, pl := range gsPlayers {
|
for _, pl := range gsPlayers {
|
||||||
r := simulatePlayer(c, pl, stats)
|
r := simulatePlayer(c, pl, stats)
|
||||||
fmt.Printf("\n%s player\n", pl.Name)
|
fmt.Printf("\n%s player\n", pl.Name)
|
||||||
fmt.Printf("%7s %9s %9s %6s %5s %5s %3s %-26s %-26s %-34s %-28s %s\n", "at", "credits", "₥/h", "₥/clk", "clk/m", "tools", "gr",
|
fmt.Printf("%7s %9s %9s %6s %5s %5s %3s %-26s %-28s %-28s %s\n", "at", "credits", "₥/h", "₥/clk", "clk/m", "tools", "gr",
|
||||||
"best copy (/min, /clk)", "best upgrade", "best attachment", "best advisor", "buys")
|
"best copy (/min, /clk)", "best upgrade", "best advisor", "buys")
|
||||||
cell := func(b GSBuy) string {
|
cell := func(b GSBuy) string {
|
||||||
if b.Kind == "" {
|
if b.Kind == "" {
|
||||||
return "-"
|
return "-"
|
||||||
@@ -635,9 +652,9 @@ func printGradeSim(c *GameConfig) {
|
|||||||
return fmt.Sprintf("%s %.3g %.3g", b.Name, b.PerMin(), b.PerClick())
|
return fmt.Sprintf("%s %.3g %.3g", b.Name, b.PerMin(), b.PerClick())
|
||||||
}
|
}
|
||||||
for _, row := range r.Rows {
|
for _, row := range r.Rows {
|
||||||
fmt.Printf("%7s %9.0f %9.0f %6.0f %5.1f %5d %3s %-26s %-26s %-34s %-28s %s\n", row.At, row.Credits, row.Income,
|
fmt.Printf("%7s %9.0f %9.0f %6.0f %5.1f %5d %3s %-26s %-28s %-28s %s\n", row.At, row.Credits, row.Income,
|
||||||
row.PerClick, row.ClickMin, row.Tools, row.Grade, cell(row.Best["copy"]), cell(row.Best["upgrade"]),
|
row.PerClick, row.ClickMin, row.Tools, row.Grade, cell(row.Best["copy"]), cell(row.Best["upgrade"]),
|
||||||
cell(row.Best["attachment"]), cell(row.Best["advisor"]), row.BestKind)
|
cell(row.Best["advisor"]), row.BestKind)
|
||||||
}
|
}
|
||||||
fmt.Print("first moon of each grade (clicks by then):")
|
fmt.Print("first moon of each grade (clicks by then):")
|
||||||
for _, g := range c.MoonGrades {
|
for _, g := range c.MoonGrades {
|
||||||
@@ -654,7 +671,7 @@ func printGradeSim(c *GameConfig) {
|
|||||||
fmt.Printf("\nlongest stretch with nothing to click: %s (from %s); with nothing to buy: %s (from %s), before the top grade %s\nbought: %v\n",
|
fmt.Printf("\nlongest stretch with nothing to click: %s (from %s); with nothing to buy: %s (from %s), before the top grade %s\nbought: %v\n",
|
||||||
r.Idle, r.IdleAt, r.BuyGap, r.BuyGapAt, r.BuyGapPreS, r.Bought)
|
r.Idle, r.IdleAt, r.BuyGap, r.BuyGapAt, r.BuyGapPreS, r.Bought)
|
||||||
fmt.Print("purchases (count, ₥ spent, ₥/min and ₥/click added per 1k₥):")
|
fmt.Print("purchases (count, ₥ spent, ₥/min and ₥/click added per 1k₥):")
|
||||||
for _, k := range []string{"copy", "upgrade", "attachment", "advisor"} {
|
for _, k := range []string{"copy", "upgrade", "advisor"} {
|
||||||
if sp := r.Spent[k]; sp > 0 {
|
if sp := r.Spent[k]; sp > 0 {
|
||||||
fmt.Printf(" %s %d %s %.3g %.3g;", k, r.Bought[k], formatCompact(sp), r.GainMin[k]/sp*1000, r.GainClick[k]/sp*1000)
|
fmt.Printf(" %s %d %s %.3g %.3g;", k, r.Bought[k], formatCompact(sp), r.GainMin[k]/sp*1000, r.GainClick[k]/sp*1000)
|
||||||
}
|
}
|
||||||
@@ -664,8 +681,8 @@ func printGradeSim(c *GameConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// printBoostTable compares, for each drill alone on the grade it's meant for, a second copy
|
// printBoostTable compares, for each drill alone on the grade it's meant for, a second copy
|
||||||
// with its boosts: what each adds per 1k₥, in ₥/minute and ₥/click; boosts as a ratio to
|
// with its upgrades: what each adds per 1k₥, in ₥/minute and ₥/click; upgrades as a ratio
|
||||||
// the copy.
|
// to the copy: each track's first level, the whole track, and both tracks to the top.
|
||||||
func printBoostTable(c *GameConfig, stats []GradeStat) {
|
func printBoostTable(c *GameConfig, stats []GradeStat) {
|
||||||
capsBy := map[int]ReachCaps{}
|
capsBy := map[int]ReachCaps{}
|
||||||
caps := func(g int) ReachCaps {
|
caps := func(g int) ReachCaps {
|
||||||
@@ -674,33 +691,41 @@ func printBoostTable(c *GameConfig, stats []GradeStat) {
|
|||||||
}
|
}
|
||||||
return capsBy[g]
|
return capsBy[g]
|
||||||
}
|
}
|
||||||
fmt.Printf("\na drill alone on its grade: a copy (₥/min, ₥/click per 1k₥), boosts ÷ copy (₥/min, ₥/click)\n%-9s %2s %13s %13s", "drill", "gr", "copy", "upgrades t1-5")
|
fmt.Printf("\na drill alone on its grade: a copy (₥/min, ₥/click per 1k₥), upgrades ÷ copy (₥/min, ₥/click)\n%-9s %2s %13s %15s %15s %15s %15s %15s\n",
|
||||||
for _, m := range c.Attachments.Items {
|
"drill", "gr", "copy", "yield 1", "yield all", "cycle 1", "cycle all", "both all")
|
||||||
fmt.Printf(" %13s", m.Name)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
for i := range c.StoreItems {
|
for i := range c.StoreItems {
|
||||||
it := &c.StoreItems[i]
|
it := &c.StoreItems[i]
|
||||||
g := c.gradeIndex(suitedGrade(c, it).Grade)
|
g := c.gradeIndex(suitedGrade(c, it).Grade)
|
||||||
base := []gsGroup{{i, 0, nil, 1, 0}}
|
inc0, clk0 := gsEval(c, stats, caps, g, []gsGroup{{i, Levels{}, 1, 0}}, nil)
|
||||||
inc0, clk0 := gsEval(c, stats, caps, g, base, nil)
|
|
||||||
per := func(gs []gsGroup, cost float64) (float64, float64) {
|
per := func(gs []gsGroup, cost float64) (float64, float64) {
|
||||||
inc, clk := gsEval(c, stats, caps, g, gs, nil)
|
inc, clk := gsEval(c, stats, caps, g, gs, nil)
|
||||||
return (inc - inc0) / 60 / cost * 1000, (inc/clk - inc0/clk0) / cost * 1000
|
return (inc - inc0) / 60 / cost * 1000, (inc/clk - inc0/clk0) / cost * 1000
|
||||||
}
|
}
|
||||||
cm, cc := per([]gsGroup{{i, 0, nil, 2, 0}}, it.Price)
|
cm, cc := per([]gsGroup{{i, Levels{}, 2, 0}}, it.Price)
|
||||||
fmt.Printf("%-9s %2s %6.3g %6.3g", toolCode(it.Name), c.MoonGrades[g].Grade, cm, cc)
|
fmt.Printf("%-9s %2s %6.3g %6.3g", toolCode(it.Name), c.MoonGrades[g].Grade, cm, cc)
|
||||||
cost, top := 0.0, 0
|
cost := func(kind string, upTo int) float64 {
|
||||||
for _, u := range it.Upgrades {
|
sum := 0.0
|
||||||
cost += u.Price + partsValue(c, u.Parts)
|
for n := 1; n <= upTo; n++ {
|
||||||
top = max(top, u.Tier)
|
if l := it.TrackLevel(kind, n); l != nil {
|
||||||
|
sum += l.Price + partsValue(c, l.Parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum
|
||||||
}
|
}
|
||||||
if top > 0 {
|
top := it.MaxLevels()
|
||||||
m, k := per([]gsGroup{{i, top, nil, 1, 0}}, cost)
|
for _, v := range []struct {
|
||||||
fmt.Printf(" ×%5.2f ×%5.2f", m/cm, k/cc)
|
lv Levels
|
||||||
}
|
cost float64
|
||||||
for _, a := range c.Attachments.Items {
|
}{
|
||||||
m, k := per([]gsGroup{{i, 0, []string{a.Name}, 1, 0}}, a.Price)
|
{Levels{Yield: 1}, cost("yield", 1)}, {Levels{Yield: top.Yield}, cost("yield", top.Yield)},
|
||||||
|
{Levels{Cycle: 1}, cost("cycle", 1)}, {Levels{Cycle: top.Cycle}, cost("cycle", top.Cycle)},
|
||||||
|
{top, cost("yield", top.Yield) + cost("cycle", top.Cycle)},
|
||||||
|
} {
|
||||||
|
if v.cost <= 0 {
|
||||||
|
fmt.Printf(" %13s", "-")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, k := per([]gsGroup{{i, v.lv, 1, 0}}, v.cost)
|
||||||
fmt.Printf(" ×%5.2f ×%5.2f", m/cm, k/cc)
|
fmt.Printf(" ×%5.2f ×%5.2f", m/cm, k/cc)
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// The Leaderboard section: the richest miners by credit balance, with the player's own
|
// The Leaderboard section: the richest miners by credit balance, with the player's own
|
||||||
// place below the top when they're not in it. Banned players don't rank. Template:
|
// place below the top when they're not in it. Banned players don't rank.
|
||||||
// leaderboard-section in app.html.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|||||||
+14
-18
@@ -12,6 +12,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
@@ -27,8 +28,10 @@ func logEvent(ctx context.Context, q querier, userID, kind string, data map[stri
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Credits spent are logged as "price" on these kinds, earned as "credits" on sell.
|
// Credits spent are logged as "price" on spendKinds, earned as "credits" on earnKinds.
|
||||||
const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'buy_attachment', 'hire_advisor', 'buy_seat'"
|
// buy_attachment: attachments were sold until migration 020 (kept for older events).
|
||||||
|
const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'upgrade_batch', 'buy_attachment', 'hire_advisor', 'buy_seat'"
|
||||||
|
const earnKinds = "'sell', 'sell_moon', 'sell_tool'"
|
||||||
|
|
||||||
// ------------------------------------------------------------------ dashboard
|
// ------------------------------------------------------------------ dashboard
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ func loadDashboard(ctx context.Context, q querier) (*Dashboard, error) {
|
|||||||
where created_at >= day and created_at < day + 1 and kind not in ('session', 'new_player')
|
where created_at >= day and created_at < day + 1 and kind not in ('session', 'new_player')
|
||||||
and kind not like 'admin\_%'),
|
and kind not like 'admin\_%'),
|
||||||
(select coalesce(sum((data->>'credits')::float8), 0) from event_log
|
(select coalesce(sum((data->>'credits')::float8), 0) from event_log
|
||||||
where created_at >= day and created_at < day + 1 and kind = 'sell'),
|
where created_at >= day and created_at < day + 1 and kind in (`+earnKinds+`)),
|
||||||
(select coalesce(sum((data->>'price')::float8), 0) from event_log
|
(select coalesce(sum((data->>'price')::float8), 0) from event_log
|
||||||
where created_at >= day and created_at < day + 1 and kind in (`+spendKinds+`))
|
where created_at >= day and created_at < day + 1 and kind in (`+spendKinds+`))
|
||||||
from days order by day`)
|
from days order by day`)
|
||||||
@@ -124,20 +127,21 @@ func loadDashboard(ctx context.Context, q querier) (*Dashboard, error) {
|
|||||||
d.ActiveToday = d.Days[n-1].Active
|
d.ActiveToday = d.Days[n-1].Active
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dn retention: of the players who joined in the last 30 days at least n days ago,
|
// Dn retention: of the players who joined in the last 30 days whose day n is over
|
||||||
// the share that did something n days after the day they joined.
|
// (joined at least n+1 days ago), the share that did something n days after the day
|
||||||
|
// they joined.
|
||||||
err = q.QueryRow(ctx, `
|
err = q.QueryRow(ctx, `
|
||||||
with cohort as (
|
with cohort as (
|
||||||
select id, created_at::date as day from users
|
select id, created_at::date as day from users
|
||||||
where created_at >= current_date - 30
|
where created_at >= current_date - 30
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
count(*) filter (where day <= current_date - 1),
|
count(*) filter (where day <= current_date - 2),
|
||||||
count(*) filter (where day <= current_date - 1 and exists (
|
count(*) filter (where day <= current_date - 2 and exists (
|
||||||
select 1 from event_log where user_id = cohort.id
|
select 1 from event_log where user_id = cohort.id
|
||||||
and created_at >= day + 1 and created_at < day + 2 and kind not like 'admin\_%')),
|
and created_at >= day + 1 and created_at < day + 2 and kind not like 'admin\_%')),
|
||||||
count(*) filter (where day <= current_date - 7),
|
count(*) filter (where day <= current_date - 8),
|
||||||
count(*) filter (where day <= current_date - 7 and exists (
|
count(*) filter (where day <= current_date - 8 and exists (
|
||||||
select 1 from event_log where user_id = cohort.id
|
select 1 from event_log where user_id = cohort.id
|
||||||
and created_at >= day + 7 and created_at < day + 8 and kind not like 'admin\_%'))
|
and created_at >= day + 7 and created_at < day + 8 and kind not like 'admin\_%'))
|
||||||
from cohort`).Scan(&d.D1.Cohort, &d.D1.Returned, &d.D7.Cohort, &d.D7.Returned)
|
from cohort`).Scan(&d.D1.Cohort, &d.D1.Returned, &d.D7.Cohort, &d.D7.Returned)
|
||||||
@@ -222,10 +226,6 @@ func (s *server) liveOps(r *http.Request) (*LiveOps, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleLiveOps(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleLiveOps(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.isAdmin(s.currentUser(r)) {
|
|
||||||
s.forbidden(w, "liveops", &LiveOps{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lo, err := s.liveOps(r)
|
lo, err := s.liveOps(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
@@ -256,10 +256,6 @@ func (s *server) handleLiveOps(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleLiveOpsAction saves, toggles or deletes an event or announcement and re-renders
|
// handleLiveOpsAction saves, toggles or deletes an event or announcement and re-renders
|
||||||
// that panel (#lo-events or #lo-announcements).
|
// that panel (#lo-events or #lo-announcements).
|
||||||
func (s *server) handleLiveOpsAction(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleLiveOpsAction(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.isAdmin(s.currentUser(r)) {
|
|
||||||
http.Error(w, "Missing permission", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
// POST /utils/liveops/{what} saves; /utils/liveops/{what}/{id}/{action} does the rest.
|
// POST /utils/liveops/{what} saves; /utils/liveops/{what}/{id}/{action} does the rest.
|
||||||
what, action := r.PathValue("what"), r.PathValue("action")
|
what, action := r.PathValue("what"), r.PathValue("action")
|
||||||
@@ -369,7 +365,7 @@ func parseAnnouncement(r *http.Request) (Announcement, string) {
|
|||||||
switch {
|
switch {
|
||||||
case a.Message == "":
|
case a.Message == "":
|
||||||
msg = "Write a message"
|
msg = "Write a message"
|
||||||
case len(a.Message) > 200:
|
case utf8.RuneCountInString(a.Message) > 200: // as the form's maxlength counts
|
||||||
msg = "Keep the message under 200 characters"
|
msg = "Keep the message under 200 characters"
|
||||||
case !slices.Contains(announcementLevels, a.Level):
|
case !slices.Contains(announcementLevels, a.Level):
|
||||||
msg = "Pick a level"
|
msg = "Pick a level"
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
// Moon Miners: a server-rendered browser mining game.
|
// Moon Miners: the game server (JSON API for the Defold client) and its admin pages.
|
||||||
//
|
//
|
||||||
// moonminers [serve] run the web server (default)
|
// moonminers [serve] run the server (default)
|
||||||
// moonminers migrate create roles/schema, apply migrations, load the game config
|
// moonminers migrate create roles/schema, apply migrations, load the game config
|
||||||
// moonminers load-config sync the store items in the database with the game config
|
// moonminers load-config sync the store items in the database with the game config
|
||||||
// moonminers sim [file] print the economy analysis for the game config (or a draft file)
|
// moonminers sim [file] print the economy analysis for the game config (or a draft file)
|
||||||
|
|||||||
+94
-101
@@ -15,6 +15,8 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -73,9 +75,84 @@ func floorDiv(a, b int64) int64 {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
// marketRateAt is the market price of a unit at a tick, before live events.
|
// marketRateAt is the market price of a unit at a tick, before live events. Ticks in the
|
||||||
|
// cached 24h window (marketSeries) are read from it; the current tick fills it.
|
||||||
func marketRateAt(name string, tick int64) float64 {
|
func marketRateAt(name string, tick int64) float64 {
|
||||||
return cfg().unitPrice(name) * (1 + cfg().MarketVolatility*marketDrift(name, tick))
|
c := cfg()
|
||||||
|
if mc := marketCached.Load(); mc != nil && mc.cfg == c && tick <= mc.tick && tick > mc.tick-marketPoints {
|
||||||
|
return mc.get(name).series[marketPoints-1-int(mc.tick-tick)]
|
||||||
|
}
|
||||||
|
if d := tick - marketTick(time.Now()); d >= -1 && d <= 1 {
|
||||||
|
return marketSeriesAt(name, tick).rate
|
||||||
|
}
|
||||||
|
return rateAt(c, name, tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rateAt(c *GameConfig, name string, tick int64) float64 {
|
||||||
|
return c.unitPrice(name) * (1 + c.MarketVolatility*marketDrift(name, tick))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ shared 24h series
|
||||||
|
|
||||||
|
// marketSeries is an item's market rate over the 24h up to a tick (marketPoints, oldest
|
||||||
|
// first) and what the market shows of it. It's shared by every player: read it, never
|
||||||
|
// modify it. Live price events aren't in it (they multiply the quote per request), so
|
||||||
|
// they never make it stale.
|
||||||
|
type marketSeries struct {
|
||||||
|
series []float64
|
||||||
|
rate, open, high, low, change float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// marketTickCache holds the series of one tick under one config snapshot (cfg() is a new
|
||||||
|
// pointer whenever the economy editor or another instance changes the config).
|
||||||
|
type marketTickCache struct {
|
||||||
|
cfg *GameConfig
|
||||||
|
tick int64
|
||||||
|
items sync.Map // name → *marketSeries
|
||||||
|
}
|
||||||
|
|
||||||
|
var marketCached atomic.Pointer[marketTickCache]
|
||||||
|
|
||||||
|
func (mc *marketTickCache) get(name string) *marketSeries {
|
||||||
|
if v, ok := mc.items.Load(name); ok {
|
||||||
|
return v.(*marketSeries)
|
||||||
|
}
|
||||||
|
v, _ := mc.items.LoadOrStore(name, newMarketSeries(mc.cfg, name, mc.tick))
|
||||||
|
return v.(*marketSeries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// marketSeriesAt is an item's series up to tick, cached for the newest tick asked for.
|
||||||
|
func marketSeriesAt(name string, tick int64) *marketSeries {
|
||||||
|
c := cfg()
|
||||||
|
mc := marketCached.Load()
|
||||||
|
if mc == nil || mc.cfg != c || mc.tick < tick {
|
||||||
|
next := &marketTickCache{cfg: c, tick: tick}
|
||||||
|
if marketCached.CompareAndSwap(mc, next) {
|
||||||
|
mc = next
|
||||||
|
} else {
|
||||||
|
mc = marketCached.Load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mc == nil || mc.cfg != c || mc.tick != tick {
|
||||||
|
return newMarketSeries(c, name, tick) // an older tick, or a racing config change
|
||||||
|
}
|
||||||
|
return mc.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMarketSeries(c *GameConfig, name string, tick int64) *marketSeries {
|
||||||
|
m := &marketSeries{series: make([]float64, marketPoints)}
|
||||||
|
for i := range m.series {
|
||||||
|
m.series[i] = rateAt(c, name, tick-int64(marketPoints-1-i))
|
||||||
|
}
|
||||||
|
m.rate, m.open = m.series[marketPoints-1], m.series[0]
|
||||||
|
m.high, m.low = m.series[0], m.series[0]
|
||||||
|
for _, p := range m.series {
|
||||||
|
m.high, m.low = max(m.high, p), min(m.low, p)
|
||||||
|
}
|
||||||
|
if m.open > 0 {
|
||||||
|
m.change = (m.rate/m.open - 1) * 100
|
||||||
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// marketRate is the market price of a unit at time t, before live events.
|
// marketRate is the market price of a unit at time t, before live events.
|
||||||
@@ -189,13 +266,14 @@ func loadPressure(ctx context.Context, q querier, s *State) error {
|
|||||||
// PriceBoosts are the active price_multiplier events: by resource name, and "all".
|
// PriceBoosts are the active price_multiplier events: by resource name, and "all".
|
||||||
type PriceBoosts map[string]float64
|
type PriceBoosts map[string]float64
|
||||||
|
|
||||||
// For is the combined multiplier for a resource (same rule as eventMultiplier).
|
// For is the combined multiplier for a resource (same rule as eventMultiplier); For("all")
|
||||||
|
// is the "all" events alone.
|
||||||
func (b PriceBoosts) For(name string) float64 {
|
func (b PriceBoosts) For(name string) float64 {
|
||||||
m := 1.0
|
m := 1.0
|
||||||
if v, ok := b["all"]; ok {
|
if v, ok := b["all"]; ok {
|
||||||
m *= v
|
m *= v
|
||||||
}
|
}
|
||||||
if v, ok := b[name]; ok {
|
if v, ok := b[name]; ok && name != "all" {
|
||||||
m *= v
|
m *= v
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
@@ -249,10 +327,9 @@ type Quote struct {
|
|||||||
High float64 // 24h high/low of the market rate
|
High float64 // 24h high/low of the market rate
|
||||||
Low float64
|
Low float64
|
||||||
Value float64 // Balance × Price (at the quote)
|
Value float64 // Balance × Price (at the quote)
|
||||||
Spark string // SVG path, viewBox 0 0 100 32
|
|
||||||
URL string
|
URL string
|
||||||
Selected bool
|
Selected bool
|
||||||
series []float64
|
series []float64 // the shared 24h market rate (marketSeries): read only
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q Quote) Up() bool { return q.Change >= 0 }
|
func (q Quote) Up() bool { return q.Change >= 0 }
|
||||||
@@ -267,7 +344,7 @@ func (q Quote) PressureText() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unpressed is what one unit pays the player before selling pressure (quote × advisors);
|
// Unpressed is what one unit pays the player before selling pressure (quote × advisors);
|
||||||
// the sell ticket (market.js) applies the pressure curve to its amount.
|
// the sell ticket applies the pressure curve to its amount.
|
||||||
func (q Quote) Unpressed() float64 { return q.Price * q.Advisor }
|
func (q Quote) Unpressed() float64 { return q.Price * q.Advisor }
|
||||||
|
|
||||||
// Unit is what one unit pays the player now (a tiny sale).
|
// Unit is what one unit pays the player now (a tiny sale).
|
||||||
@@ -278,8 +355,7 @@ func (q Quote) SellAll() float64 {
|
|||||||
return sellPayout(q.Balance, q.Price, q.Advisor, cfg().MarketPressure.Average(q.Pressure, q.Balance*q.Base()))
|
return sellPayout(q.Balance, q.Price, q.Advisor, cfg().MarketPressure.Average(q.Pressure, q.Balance*q.Base()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q Quote) Base() float64 { return cfg().unitPrice(q.Resource) }
|
func (q Quote) Base() float64 { return cfg().unitPrice(q.Resource) }
|
||||||
func (q Quote) Curve() MarketPressure { return cfg().MarketPressure }
|
|
||||||
|
|
||||||
// AdvisorText is e.g. "+8%".
|
// AdvisorText is e.g. "+8%".
|
||||||
func (q Quote) AdvisorText() string { return pctText(q.Advisor) }
|
func (q Quote) AdvisorText() string { return pctText(q.Advisor) }
|
||||||
@@ -311,34 +387,12 @@ func marketSymbol(name string) string {
|
|||||||
return strings.ToUpper(name)
|
return strings.ToUpper(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarketChart is the selected quote's 24h chart, viewBox 0 0 ChartW ChartH.
|
// MarketChart is the selected quote's 24h chart: its points (the quote's series) start at
|
||||||
|
// Start, unix ms, Step ms apart.
|
||||||
type MarketChart struct {
|
type MarketChart struct {
|
||||||
Line, Area string
|
Start, Step int64
|
||||||
Prices string // comma-separated, for the crosshair (market.js)
|
|
||||||
Start, Step int64 // unix ms of the first point, ms between points
|
|
||||||
HighY, LowY float64
|
|
||||||
Grid []GridLine
|
|
||||||
Times []TimeLabel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GridLine struct {
|
|
||||||
Y float64 // in viewBox units
|
|
||||||
Top float64 // the same, as a percent of the height
|
|
||||||
Label string
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimeLabel struct {
|
|
||||||
X float64 // percent
|
|
||||||
Label string
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
chartW = 600
|
|
||||||
chartH = 220
|
|
||||||
sparkW = 100
|
|
||||||
sparkH = 32
|
|
||||||
)
|
|
||||||
|
|
||||||
// marketPage builds the market from the rows the market section lists (resources and
|
// marketPage builds the market from the rows the market section lists (resources and
|
||||||
// refined items).
|
// refined items).
|
||||||
func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
||||||
@@ -347,23 +401,13 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
q := Quote{MarketRow: row, Boost: s.PriceBoosts.For(row.Resource), Advisor: s.Bonus().Sell,
|
q := Quote{MarketRow: row, Boost: s.PriceBoosts.For(row.Resource), Advisor: s.Bonus().Sell,
|
||||||
URL: View{Section: "market", Moon: v.Moon, Pick: row.Resource}.URL()}
|
URL: View{Section: "market", Moon: v.Moon, Pick: row.Resource}.URL()}
|
||||||
q.series = make([]float64, marketPoints)
|
m := marketSeriesAt(row.Resource, now)
|
||||||
for i := range q.series {
|
q.series = m.series
|
||||||
q.series[i] = marketRateAt(row.Resource, now-int64(marketPoints-1-i))
|
q.Rate, q.Open, q.High, q.Low, q.Change = m.rate, m.open, m.high, m.low, m.change
|
||||||
}
|
|
||||||
q.Rate, q.Open = q.series[marketPoints-1], q.series[0]
|
|
||||||
q.High, q.Low = q.series[0], q.series[0]
|
|
||||||
for _, p := range q.series {
|
|
||||||
q.High, q.Low = max(q.High, p), min(q.Low, p)
|
|
||||||
}
|
|
||||||
if q.Open > 0 {
|
|
||||||
q.Change = (q.Rate/q.Open - 1) * 100
|
|
||||||
}
|
|
||||||
q.Pressure = s.Pressure[row.Resource]
|
q.Pressure = s.Pressure[row.Resource]
|
||||||
q.Factor = cfg().MarketPressure.Factor(q.Pressure)
|
q.Factor = cfg().MarketPressure.Factor(q.Pressure)
|
||||||
q.Price = q.Rate * q.Boost
|
q.Price = q.Rate * q.Boost
|
||||||
q.Value = q.Balance * q.Price
|
q.Value = q.Balance * q.Price
|
||||||
q.Spark, _ = chartPaths(q.series, 3, sparkW, sparkH, q.Low, q.High)
|
|
||||||
mp.Quotes = append(mp.Quotes, q)
|
mp.Quotes = append(mp.Quotes, q)
|
||||||
}
|
}
|
||||||
for i := range mp.Quotes {
|
for i := range mp.Quotes {
|
||||||
@@ -387,62 +431,11 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
|
|||||||
}
|
}
|
||||||
if q := mp.Selected; q != nil {
|
if q := mp.Selected; q != nil {
|
||||||
q.Selected = true
|
q.Selected = true
|
||||||
mp.Chart = s.marketChart(q, now)
|
mp.Chart = marketChart(now)
|
||||||
}
|
}
|
||||||
return mp
|
return mp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) marketChart(q *Quote, now int64) MarketChart {
|
func marketChart(now int64) MarketChart {
|
||||||
// Pad the range so the line doesn't touch the edges.
|
return MarketChart{Step: marketStep.Milliseconds(), Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
|
||||||
pad := max((q.High-q.Low)*0.12, q.Rate*0.005)
|
|
||||||
lo, hi := q.Low-pad, q.High+pad
|
|
||||||
c := MarketChart{Step: marketStep.Milliseconds(),
|
|
||||||
Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
|
|
||||||
c.Line, c.Area = chartPaths(q.series, 1, chartW, chartH, lo, hi)
|
|
||||||
y := func(p float64) float64 { return chartH - (p-lo)/(hi-lo)*chartH }
|
|
||||||
c.HighY, c.LowY = y(q.High), y(q.Low)
|
|
||||||
prices := make([]string, len(q.series))
|
|
||||||
for i, p := range q.series {
|
|
||||||
prices[i] = strconv.FormatFloat(p, 'f', 4, 64)
|
|
||||||
}
|
|
||||||
c.Prices = strings.Join(prices, ",")
|
|
||||||
for i := 1; i <= 3; i++ {
|
|
||||||
p := lo + (hi-lo)*float64(i)/4
|
|
||||||
c.Grid = append(c.Grid, GridLine{Y: y(p), Top: y(p) / chartH * 100, Label: formatCredits(p)})
|
|
||||||
}
|
|
||||||
for h := 0; h <= 24; h += 6 {
|
|
||||||
label := fmt.Sprintf("−%dh", 24-h)
|
|
||||||
if h == 24 {
|
|
||||||
label = "Now"
|
|
||||||
}
|
|
||||||
c.Times = append(c.Times, TimeLabel{X: float64(h) / 24 * 100, Label: label})
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// chartPaths returns an SVG line path through every nth point of series, scaled to
|
|
||||||
// w×h between lo and hi, and the area under it.
|
|
||||||
func chartPaths(series []float64, every int, w, h, lo, hi float64) (line, area string) {
|
|
||||||
if len(series) < 2 {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
if hi <= lo {
|
|
||||||
lo, hi = lo-1, hi+1
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
last := len(series) - 1
|
|
||||||
for i := 0; i <= last; i += every {
|
|
||||||
if i+every > last {
|
|
||||||
i = last
|
|
||||||
}
|
|
||||||
x := float64(i) / float64(last) * w
|
|
||||||
y := h - (series[i]-lo)/(hi-lo)*h
|
|
||||||
cmd := "L"
|
|
||||||
if i == 0 {
|
|
||||||
cmd = "M"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s%.1f %.1f", cmd, x, y)
|
|
||||||
}
|
|
||||||
line = b.String()
|
|
||||||
return line, fmt.Sprintf("%sL%.1f %.1fL0 %.1fZ", line, w, h, h)
|
|
||||||
}
|
}
|
||||||
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user