fix
This commit is contained in:
@@ -7,3 +7,4 @@ Dockerfile
|
||||
.dockerignore
|
||||
web/e2e
|
||||
node_modules
|
||||
**/__pycache__
|
||||
|
||||
+22
-5
@@ -12,6 +12,19 @@ COPY . .
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
go vet ./... && go test ./...
|
||||
|
||||
# ---- manual: an on-demand renderer for the PDF manual -----------------------
|
||||
# Not part of the app image, so deploying the app never waits for it. Running
|
||||
# this image renders docs/manual.md to /out/manual.pdf (typeset with WeasyPrint,
|
||||
# then coffee-stained, hole-punched and photocopied; see docs/pdf/). The file is
|
||||
# replaced atomically. Use scripts/render-manual.sh (compose volume) or
|
||||
# scripts/manual-pdf.sh (a local file).
|
||||
FROM alpine:3.20 AS manual
|
||||
RUN apk add --no-cache weasyprint py3-markdown py3-pillow py3-numpy font-liberation poppler-utils
|
||||
WORKDIR /manual
|
||||
COPY docs/pdf ./pdf
|
||||
COPY docs/manual.md .
|
||||
ENTRYPOINT ["python3", "pdf/render.py", "manual.md", "/out/manual.pdf"]
|
||||
|
||||
# ---- build: static binaries (SQLite and Postgres drivers are pure Go) --------
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
@@ -21,23 +34,27 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache
|
||||
|
||||
# ---- final: small runtime image ----------------------------------------------
|
||||
FROM alpine:3.20
|
||||
RUN addgroup -S -g 10001 wh && adduser -S -u 10001 -G wh wh \
|
||||
&& mkdir /data && chown wh:wh /data
|
||||
RUN addgroup -S -g 10001 halcyon && adduser -S -u 10001 -G halcyon halcyon \
|
||||
&& mkdir /data && chown halcyon:halcyon /data
|
||||
COPY --from=build /out/server /out/daily /out/asm /usr/local/bin/
|
||||
|
||||
USER wh
|
||||
USER halcyon
|
||||
WORKDIR /data
|
||||
|
||||
# Where the world lives: "sqlite:<path>" or "postgres://user:pass@host/db".
|
||||
# The default is a SQLite file on the /data volume; override it at run time,
|
||||
# e.g. -e DATABASE_URL=postgres://... (the -db flag also overrides it).
|
||||
ENV DATABASE_URL=sqlite:/data/wh.db
|
||||
ENV DATABASE_URL=sqlite:/data/halcyon.db
|
||||
|
||||
# The rendered PDF manual is read from here on every request. Compose mounts the
|
||||
# volume the manual renderer writes to; if the file is absent /manual.pdf is a 404.
|
||||
ENV MANUAL_PDF=/manual/manual.pdf
|
||||
VOLUME /data
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8080/info || exit 1
|
||||
|
||||
# Default: the API and website. Run one simulated day with:
|
||||
# Default: the API and website. Run one day of the belt with:
|
||||
# docker run --rm -e DATABASE_URL=... <image> daily
|
||||
CMD ["server"]
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
# Halcyon belt
|
||||
# Halcyon
|
||||
|
||||
A once-a-day asteroid-belt simulator. Players cannot fly their ships: they write a
|
||||
A once-a-day asteroid-belt game. Players cannot fly their ships: they write a
|
||||
program for the ship's flight computer, launch it, and then exchange 1 KB a day with
|
||||
it over a "wormhole" link. The belt is simulated once a day, deterministically.
|
||||
it over a "wormhole" link. The belt is run once a day, deterministically.
|
||||
|
||||
- `docs/manual.txt` -- the HC-33 flight computer programmer's manual (also served at `/manual.txt`)
|
||||
- `docs/manual.md` -- the HC-33 flight computer programmer's manual (source; served at `/manual.md`)
|
||||
- `/manual.pdf` -- the same, typeset separately (see below)
|
||||
- `examples/programs/` -- sample ship programs (assembly)
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
go run ./cmd/server -db sqlite:wh.db # API + website on :8080
|
||||
go run ./cmd/daily -db sqlite:wh.db # simulate one day (run once a day, e.g. from cron)
|
||||
go run ./cmd/server -db sqlite:halcyon.db # API + website on :8080
|
||||
go run ./cmd/daily -db sqlite:halcyon.db # run one day of the belt (once a day, e.g. from cron)
|
||||
```
|
||||
|
||||
`-db` is `sqlite:<path>` or a `postgres://` URL. The world parameters (`-seed`,
|
||||
`-ticks-per-day`, `-asteroids`, ...) or their `WH_*` variables must be the same for `server` and
|
||||
`-ticks-per-day`, `-asteroids`, ...) or their `HALCYON_*` variables must be the same for `server` and
|
||||
`daily`, and cannot be changed for an existing database.
|
||||
|
||||
Open <http://localhost:8080/>, create an account (you are shown an access key once;
|
||||
@@ -26,27 +27,47 @@ it is your login), write a program, seal it into your ship, launch it, then run
|
||||
```sh
|
||||
docker build -t halcyon .
|
||||
docker run -d -p 8080:8080 -v whdata:/data halcyon # SQLite on a volume
|
||||
docker run --rm -v whdata:/data halcyon daily # simulate one day
|
||||
docker run -d -p 8080:8080 -e DATABASE_URL=postgres://user:pw@db/wh halcyon # or Postgres
|
||||
docker run --rm -v whdata:/data halcyon daily # run one day of the belt
|
||||
docker run -d -p 8080:8080 -e DATABASE_URL=postgres://user:pw@db/halcyon halcyon # or Postgres
|
||||
docker build --target test . # run the tests
|
||||
scripts/render-manual.sh # (re)render /manual.pdf, separately
|
||||
```
|
||||
|
||||
With Compose, Caddy serves the site over HTTPS at `sandbox.clearsky.dev` (edit `caddy/Caddyfile`
|
||||
for another name; DNS must point at the host and ports 80/443 must be open). The app itself is
|
||||
also on <http://localhost:8070>, bound to localhost only. SQLite lives in the `halcyon-data` volume:
|
||||
also on <http://localhost:8070>, bound to localhost only. SQLite lives in the `halcyon_data` volume:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
scripts/daily.sh # one simulated day via `docker exec`; schedule it once a day
|
||||
scripts/daily.sh # one day's run via `docker exec`; schedule it once a day
|
||||
```
|
||||
|
||||
The world settings are environment variables (`WH_SEED`, `WH_TICKS_PER_DAY`, `WH_CYCLES_PER_TICK`,
|
||||
`WH_PROGRAM_BYTES`, `WH_RAM_BYTES`, `WH_COMM_BYTES`, `WH_ASTEROIDS`); flags of the same name override
|
||||
The world settings are environment variables (`HALCYON_SEED`, `HALCYON_TICKS_PER_DAY`, `HALCYON_CYCLES_PER_TICK`,
|
||||
`HALCYON_PROGRAM_BYTES`, `HALCYON_RAM_BYTES`, `HALCYON_COMM_BYTES`, `HALCYON_ASTEROIDS`); flags of the same name override
|
||||
them. In Compose they are set once on the `web` service, so `scripts/daily.sh` always matches the
|
||||
server. Change them only before a world has run.
|
||||
|
||||
`DATABASE_URL` sets the database for both `server` and `daily` (default in the image:
|
||||
`sqlite:/data/wh.db`); the `-db` flag overrides it. The image also contains `asm`.
|
||||
`sqlite:/data/halcyon.db`); the `-db` flag overrides it. The image also contains `asm`.
|
||||
|
||||
## The manual PDF
|
||||
|
||||
`docs/manual.md` is rendered to a PDF: typeset with WeasyPrint, then made to look like a well-used ring-binder
|
||||
copy -- coffee rings, three binder holes, and several generations of photocopying. The PDF keeps a live text
|
||||
layer, bookmarks and links. The look is generated by `docs/pdf/render.py` and is deterministic;
|
||||
`--intensity` (default 0.5) sets how worn the copies look, and `docs/pdf/check_legibility.py` OCRs every page
|
||||
against the text layer to check the result.
|
||||
|
||||
Rendering takes a couple of minutes, so it is **not** part of the app image: deploys stay fast, and the PDF is
|
||||
rendered separately into a volume that the server reads on every request (a new render is live at once, no restart):
|
||||
|
||||
```sh
|
||||
docker compose up -d --build # deploy the app (fast)
|
||||
scripts/render-manual.sh > /tmp/render-manual.log 2>&1 & # then re-render the PDF in the background
|
||||
scripts/manual-pdf.sh # or: render to docs/generated/ for a locally run server
|
||||
```
|
||||
|
||||
Until it has been rendered once, `/manual.pdf` is a 404 that says so. The Markdown source is always at `/manual.md`.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -54,12 +75,12 @@ server. Change them only before a world has run.
|
||||
|---|---|
|
||||
| `fixed`, `rng` | deterministic Q32.32 maths and PRNG |
|
||||
| `vm` | the ship CPU and its assembler (`cmd/asm`) |
|
||||
| `world`, `sim`, `market` | belt generation, the daily simulation, ore prices |
|
||||
| `world`, `sim`, `market` | belt generation, the daily run, ore prices |
|
||||
| `store`, `runner` | SQLite/Postgres persistence and the daily run |
|
||||
| `api`, `web`, `cmd/server` | HTTP API and the browser front end |
|
||||
| `examples`, `docs` | embedded sample programs and manual |
|
||||
|
||||
## Tests
|
||||
|
||||
`go test ./...` covers everything; set `WH_TEST_PG=postgres://...` to also run the API
|
||||
`go test ./...` covers everything; set `HALCYON_TEST_PG=postgres://...` to also run the API
|
||||
test against PostgreSQL (it wipes that database's `public` schema).
|
||||
|
||||
+35
-10
@@ -11,12 +11,12 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"wh/comms"
|
||||
"wh/config"
|
||||
"wh/runner"
|
||||
"wh/store"
|
||||
"wh/vm"
|
||||
"wh/world"
|
||||
"halcyon/comms"
|
||||
"halcyon/config"
|
||||
"halcyon/runner"
|
||||
"halcyon/store"
|
||||
"halcyon/vm"
|
||||
"halcyon/world"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -33,7 +33,9 @@ type Server struct {
|
||||
// POST /assemble assembly text -> program (base64), size
|
||||
// GET /ships -> your ships
|
||||
// PUT /ships/{id}/program raw bytecode (ship must be in inventory)
|
||||
// POST /ships/{id}/launch (enters the belt on the next run)
|
||||
// POST /ships/{id}/launch {"slot": n} (optional) (enters the belt on the next run; with a slot, that
|
||||
// slot's code is assembled and sealed in first)
|
||||
// GET /slots, GET/PUT/DELETE /slots/{n} the sixteen code slots (1-3 are read-only examples)
|
||||
// PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink)
|
||||
// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header
|
||||
//
|
||||
@@ -49,6 +51,10 @@ func New(st *store.Store, cfg config.Config) *http.ServeMux {
|
||||
mux.Handle("GET /ships", s.auth(s.ships))
|
||||
mux.Handle("PUT /ships/{id}/program", s.auth(s.program))
|
||||
mux.Handle("POST /ships/{id}/launch", s.auth(s.launch))
|
||||
mux.Handle("GET /slots", s.auth(s.listSlots))
|
||||
mux.Handle("GET /slots/{n}", s.auth(s.getSlot))
|
||||
mux.Handle("PUT /slots/{n}", s.auth(s.putSlot))
|
||||
mux.Handle("DELETE /slots/{n}", s.auth(s.deleteSlot))
|
||||
mux.Handle("PUT /ships/{id}/uplink", s.auth(s.uplink))
|
||||
mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink))
|
||||
return mux
|
||||
@@ -148,9 +154,9 @@ func (s *Server) info(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"day": s.currentDay(r.Context()),
|
||||
"ticks_per_day": s.cfg.TicksPerDay,
|
||||
"tick_seconds": s.cfg.TickSeconds(),
|
||||
"cycles_per_tick": s.cfg.CyclesPerTick,
|
||||
"turns_per_day": s.cfg.TicksPerDay,
|
||||
"turn_seconds": s.cfg.TickSeconds(),
|
||||
"cycles_per_turn": s.cfg.CyclesPerTick,
|
||||
"program_bytes": s.cfg.ProgramBytes,
|
||||
"ram_bytes": s.cfg.RAMBytes,
|
||||
"comm_bytes": s.cfg.CommBytes,
|
||||
@@ -242,6 +248,25 @@ func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// An optional {"slot": n} chooses which code slot the ship flies. Without it
|
||||
// the ship launches with the program already sealed in it (PUT .../program).
|
||||
body, ok := readBody(w, r, 1<<10)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(string(body)) != "" {
|
||||
var req struct {
|
||||
Slot *int `json:"slot"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, `body must be {"slot": <1-16>} or empty`)
|
||||
return
|
||||
}
|
||||
if req.Slot != nil {
|
||||
s.launchFromSlot(w, r, p, id, *req.Slot)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.st.Launch(r.Context(), p.ID, id); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
|
||||
+8
-8
@@ -12,12 +12,12 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"wh/api"
|
||||
"wh/config"
|
||||
"wh/runner"
|
||||
"wh/sim"
|
||||
"wh/store"
|
||||
"wh/vm"
|
||||
"halcyon/api"
|
||||
"halcyon/config"
|
||||
"halcyon/runner"
|
||||
"halcyon/sim"
|
||||
"halcyon/store"
|
||||
"halcyon/vm"
|
||||
)
|
||||
|
||||
func TestEndToEnd(t *testing.T) {
|
||||
@@ -25,10 +25,10 @@ func TestEndToEnd(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.AsteroidCount = 20
|
||||
|
||||
// Set WH_TEST_PG to a postgres:// URL to run this against PostgreSQL
|
||||
// Set HALCYON_TEST_PG to a postgres:// URL to run this against PostgreSQL
|
||||
// (its public schema is wiped first).
|
||||
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db")
|
||||
if pg := os.Getenv("WH_TEST_PG"); pg != "" {
|
||||
if pg := os.Getenv("HALCYON_TEST_PG"); pg != "" {
|
||||
db, err := sql.Open("pgx", pg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+80
-11
@@ -2,26 +2,46 @@ package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"wh/api"
|
||||
"wh/config"
|
||||
"wh/store"
|
||||
"wh/web"
|
||||
"halcyon/api"
|
||||
"halcyon/config"
|
||||
"halcyon/store"
|
||||
"halcyon/web"
|
||||
)
|
||||
|
||||
// newSite starts the API with the web front end mounted, as cmd/server does.
|
||||
func newSite(t *testing.T) (*httptest.Server, config.Config) {
|
||||
func newSite(t *testing.T, manualPDF string) (*httptest.Server, config.Config) {
|
||||
srv, cfg, _ := newSiteStore(t, manualPDF)
|
||||
return srv, cfg
|
||||
}
|
||||
|
||||
// newSiteStore is newSite, also returning the database so tests can run a simulated day.
|
||||
func newSiteStore(t *testing.T, manualPDF string) (*httptest.Server, config.Config, *store.Store) {
|
||||
t.Helper()
|
||||
cfg := config.Default()
|
||||
st, err := store.Open("sqlite:" + filepath.Join(t.TempDir(), "t.db"))
|
||||
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db")
|
||||
if pg := os.Getenv("HALCYON_TEST_PG"); pg != "" { // run against PostgreSQL, wiping its public schema first
|
||||
db, err := sql.Open("pgx", pg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
dsn = pg
|
||||
}
|
||||
st, err := store.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -30,10 +50,10 @@ func newSite(t *testing.T) (*httptest.Server, config.Config) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := api.New(st, cfg)
|
||||
mux.Handle("/", web.Handler())
|
||||
mux.Handle("/", web.Handler(manualPDF))
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, cfg
|
||||
return srv, cfg, st
|
||||
}
|
||||
|
||||
func get(t *testing.T, url string) (*http.Response, string) {
|
||||
@@ -48,7 +68,7 @@ func get(t *testing.T, url string) (*http.Response, string) {
|
||||
}
|
||||
|
||||
func TestInfoAndAssemble(t *testing.T) {
|
||||
srv, cfg := newSite(t)
|
||||
srv, cfg := newSite(t, "")
|
||||
|
||||
_, body := get(t, srv.URL+"/info")
|
||||
var info struct {
|
||||
@@ -92,10 +112,10 @@ func TestInfoAndAssemble(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebAssets(t *testing.T) {
|
||||
srv, _ := newSite(t)
|
||||
srv, _ := newSite(t, "")
|
||||
for path, want := range map[string]string{
|
||||
"/": "<title>",
|
||||
"/manual.txt": "PROGRAMMER'S REFERENCE MANUAL",
|
||||
"/manual.md": "Programmer's Reference Manual",
|
||||
"/examples/echo.s": "ECHO",
|
||||
"/examples/index.json": `"pursue"`,
|
||||
} {
|
||||
@@ -108,8 +128,57 @@ func TestWebAssets(t *testing.T) {
|
||||
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") {
|
||||
t.Error("missing CSP header")
|
||||
}
|
||||
// The PDF is rendered separately; until it exists /manual.pdf explains how.
|
||||
presp, pbody := get(t, srv.URL+"/manual.pdf")
|
||||
if presp.StatusCode != 404 || !strings.Contains(pbody, "render-manual.sh") {
|
||||
t.Errorf("/manual.pdf before any render: status %d: %s", presp.StatusCode, pbody)
|
||||
}
|
||||
// The API still wins over the catch-all.
|
||||
if resp, _ := get(t, srv.URL+"/market"); !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
|
||||
t.Errorf("/market served as %q", resp.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualPDFServedFromFileAndPickedUpLive(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "manual.pdf")
|
||||
srv, _ := newSite(t, path)
|
||||
|
||||
if resp, _ := get(t, srv.URL+"/manual.pdf"); resp.StatusCode != 404 {
|
||||
t.Fatalf("before the file exists: %d", resp.StatusCode)
|
||||
}
|
||||
write := func(content string) {
|
||||
// Like the renderer: write beside the target, then rename into place.
|
||||
if err := os.WriteFile(path+".tmp", []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(path+".tmp", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("%PDF-1.7 first render")
|
||||
resp, body := get(t, srv.URL+"/manual.pdf")
|
||||
if resp.StatusCode != 200 || body != "%PDF-1.7 first render" || resp.Header.Get("Content-Type") != "application/pdf" {
|
||||
t.Fatalf("first render: %d %q %q", resp.StatusCode, body, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
if resp.Header.Get("Cache-Control") != "no-cache" || resp.Header.Get("Content-Security-Policy") != "" {
|
||||
t.Errorf("headers: cache=%q csp=%q", resp.Header.Get("Cache-Control"), resp.Header.Get("Content-Security-Policy"))
|
||||
}
|
||||
|
||||
// A re-render is served immediately, with no restart.
|
||||
write("%PDF-1.7 second render, longer")
|
||||
if _, body := get(t, srv.URL+"/manual.pdf"); body != "%PDF-1.7 second render, longer" {
|
||||
t.Fatalf("new render not picked up: %q", body)
|
||||
}
|
||||
|
||||
// Range requests work (PDF viewers rely on them).
|
||||
req, _ := http.NewRequest("GET", srv.URL+"/manual.pdf", nil)
|
||||
req.Header.Set("Range", "bytes=0-4")
|
||||
rr, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rr.Body.Close()
|
||||
if part, _ := io.ReadAll(rr.Body); rr.StatusCode != 206 || string(part) != "%PDF-" {
|
||||
t.Fatalf("range: %d %q", rr.StatusCode, part)
|
||||
}
|
||||
}
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"halcyon/comms"
|
||||
"halcyon/slots"
|
||||
"halcyon/store"
|
||||
"halcyon/vm"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSourceBytes = 64 << 10
|
||||
maxSlotName = 40
|
||||
)
|
||||
|
||||
var (
|
||||
errBadSlot = errors.New("slot must be a number from 1 to 16")
|
||||
errEmptySlot = errors.New("that slot is empty")
|
||||
)
|
||||
|
||||
type slotView struct {
|
||||
Slot int `json:"slot"`
|
||||
Name string `json:"name"`
|
||||
Builtin bool `json:"builtin"`
|
||||
Empty bool `json:"empty"`
|
||||
}
|
||||
|
||||
func slotNumber(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || !slots.Valid(n) {
|
||||
httpError(w, http.StatusBadRequest, errBadSlot.Error())
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// slotContent returns what is in slot n for this player. An empty slot has an
|
||||
// empty source. The examples in slots 1-3 are the same for everyone.
|
||||
func (s *Server) slotContent(r *http.Request, owner int64, n int) (slots.Slot, error) {
|
||||
if !slots.Valid(n) {
|
||||
return slots.Slot{}, errBadSlot
|
||||
}
|
||||
if b, ok := slots.Builtin(n); ok {
|
||||
return b, nil
|
||||
}
|
||||
cs, err := s.st.GetCodeSlot(r.Context(), owner, n)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return slots.Slot{Number: n}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return slots.Slot{}, err
|
||||
}
|
||||
return slots.Slot{Number: n, Name: cs.Name, Source: cs.Source}, nil
|
||||
}
|
||||
|
||||
// GET /slots: the sixteen slots, without their source.
|
||||
func (s *Server) listSlots(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
saved, err := s.st.ListCodeSlots(r.Context(), p.ID)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]slotView, 0, slots.Count)
|
||||
for n := 1; n <= slots.Count; n++ {
|
||||
if b, ok := slots.Builtin(n); ok {
|
||||
out = append(out, slotView{Slot: n, Name: b.Name, Builtin: true})
|
||||
continue
|
||||
}
|
||||
name, has := saved[n]
|
||||
out = append(out, slotView{Slot: n, Name: name, Empty: !has})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GET /slots/{n}: one slot with its source.
|
||||
func (s *Server) getSlot(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
n, ok := slotNumber(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sl, err := s.slotContent(r, p.ID, n)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"slot": n, "name": sl.Name, "builtin": sl.Builtin, "empty": sl.Source == "" && sl.Name == "", "source": sl.Source,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /slots/{n}: save source in one of slots 4-16.
|
||||
func (s *Server) putSlot(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
n, ok := slotNumber(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, builtin := slots.Builtin(n); builtin {
|
||||
httpError(w, http.StatusForbidden, "slots 1 to "+strconv.Itoa(slots.FirstUser-1)+" hold the examples and are read-only; save your copy in slot "+strconv.Itoa(slots.FirstUser)+" or higher")
|
||||
return
|
||||
}
|
||||
body, ok := readBody(w, r, maxSourceBytes+4<<10)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, `body must be {"name": "...", "source": "..."}`)
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
switch {
|
||||
case req.Name == "" || len([]rune(req.Name)) > maxSlotName:
|
||||
httpError(w, http.StatusBadRequest, "name must be 1 to "+strconv.Itoa(maxSlotName)+" characters")
|
||||
return
|
||||
case len(req.Source) > maxSourceBytes:
|
||||
httpError(w, http.StatusRequestEntityTooLarge, "source is larger than "+strconv.Itoa(maxSourceBytes)+" bytes")
|
||||
return
|
||||
}
|
||||
if err := s.st.PutCodeSlot(r.Context(), p.ID, n, req.Name, req.Source, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, slotView{Slot: n, Name: req.Name})
|
||||
}
|
||||
|
||||
// DELETE /slots/{n}: empty one of slots 4-16.
|
||||
func (s *Server) deleteSlot(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
n, ok := slotNumber(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, builtin := slots.Builtin(n); builtin {
|
||||
httpError(w, http.StatusForbidden, "the example slots cannot be cleared")
|
||||
return
|
||||
}
|
||||
if err := s.st.DeleteCodeSlot(r.Context(), p.ID, n); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// launchFromSlot assembles the chosen slot and seals it into the ship as it launches.
|
||||
func (s *Server) launchFromSlot(w http.ResponseWriter, r *http.Request, p store.Player, ship int64, n int) {
|
||||
sl, err := s.slotContent(r, p.ID, n)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(sl.Source) == "" {
|
||||
httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+" is empty")
|
||||
return
|
||||
}
|
||||
prog, err := vm.Assemble(sl.Source)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+" does not assemble: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := comms.CheckProgram(prog, s.cfg.ProgramBytes); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+": "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.st.LaunchWithProgram(r.Context(), p.ID, ship, prog); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"slot": n, "program_bytes": len(prog)})
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"halcyon/runner"
|
||||
)
|
||||
|
||||
// player is a registered account talking to the API.
|
||||
type player struct {
|
||||
t *testing.T
|
||||
base string
|
||||
key string
|
||||
ship int64
|
||||
}
|
||||
|
||||
func newPlayer(t *testing.T, base, name string) *player {
|
||||
t.Helper()
|
||||
resp, err := http.Post(base+"/register", "application/json", strings.NewReader(`{"name":"`+name+`"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var r struct {
|
||||
APIKey string `json:"api_key"`
|
||||
ShipID int64 `json:"ship_id"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil || r.APIKey == "" {
|
||||
t.Fatalf("register %s: %v", name, err)
|
||||
}
|
||||
return &player{t: t, base: base, key: r.APIKey, ship: r.ShipID}
|
||||
}
|
||||
|
||||
// do sends a request and returns the body, failing unless the status is want.
|
||||
func (p *player) do(method, path string, body any, want int) []byte {
|
||||
p.t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
if s, ok := body.(string); ok {
|
||||
rdr = strings.NewReader(s)
|
||||
} else {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
}
|
||||
req, _ := http.NewRequest(method, p.base+path, rdr)
|
||||
if p.key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.key)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
p.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != want {
|
||||
p.t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type slotRow struct {
|
||||
Slot int `json:"slot"`
|
||||
Name string `json:"name"`
|
||||
Builtin bool `json:"builtin"`
|
||||
Empty bool `json:"empty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func (p *player) slots() []slotRow {
|
||||
var rows []slotRow
|
||||
json.Unmarshal(p.do("GET", "/slots", nil, 200), &rows)
|
||||
return rows
|
||||
}
|
||||
|
||||
func (p *player) slot(n int) slotRow {
|
||||
var r slotRow
|
||||
json.Unmarshal(p.do("GET", "/slots/"+itoa(int64(n)), nil, 200), &r)
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *player) shipStatus() (status string, programBytes int) {
|
||||
var ships []struct {
|
||||
Status string `json:"status"`
|
||||
ProgramBytes int `json:"program_bytes"`
|
||||
}
|
||||
json.Unmarshal(p.do("GET", "/ships", nil, 200), &ships)
|
||||
return ships[0].Status, ships[0].ProgramBytes
|
||||
}
|
||||
|
||||
func TestSlotsListAndExamples(t *testing.T) {
|
||||
srv, _ := newSite(t, "")
|
||||
a := newPlayer(t, srv.URL, "ann")
|
||||
|
||||
rows := a.slots()
|
||||
if len(rows) != 16 {
|
||||
t.Fatalf("%d slots, want 16", len(rows))
|
||||
}
|
||||
for i, want := range []string{"ECHO", "TELEMETRY", "PURSUE"} {
|
||||
if r := rows[i]; r.Slot != i+1 || r.Name != want || !r.Builtin || r.Empty {
|
||||
t.Errorf("slot %d = %+v, want the %s example", i+1, r, want)
|
||||
}
|
||||
}
|
||||
for _, r := range rows[3:] {
|
||||
if r.Builtin || !r.Empty || r.Name != "" {
|
||||
t.Errorf("slot %d should start empty: %+v", r.Slot, r)
|
||||
}
|
||||
}
|
||||
if s := a.slot(2); !s.Builtin || !strings.Contains(s.Source, "TELEMETRY") {
|
||||
t.Errorf("slot 2 = %+v", s)
|
||||
}
|
||||
if s := a.slot(9); !s.Empty || s.Source != "" {
|
||||
t.Errorf("empty slot 9 = %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotsSaveLoadClearAndIsolation(t *testing.T) {
|
||||
srv, _ := newSite(t, "")
|
||||
a, b := newPlayer(t, srv.URL, "ann"), newPlayer(t, srv.URL, "bob")
|
||||
|
||||
a.do("PUT", "/slots/5", map[string]string{"name": "MINER", "source": "ldi r0, 1\nhalt"}, 200)
|
||||
if s := a.slot(5); s.Name != "MINER" || s.Source != "ldi r0, 1\nhalt" || s.Empty || s.Builtin {
|
||||
t.Fatalf("saved slot = %+v", s)
|
||||
}
|
||||
if r := a.slots()[4]; r.Name != "MINER" || r.Empty {
|
||||
t.Fatalf("listing = %+v", r)
|
||||
}
|
||||
// Saving again replaces it, including the name.
|
||||
a.do("PUT", "/slots/5", map[string]string{"name": "MINER 2", "source": "halt"}, 200)
|
||||
if s := a.slot(5); s.Name != "MINER 2" || s.Source != "halt" {
|
||||
t.Fatalf("overwritten slot = %+v", s)
|
||||
}
|
||||
// Slots are private to their owner.
|
||||
if s := b.slot(5); !s.Empty {
|
||||
t.Fatalf("bob can see ann's slot: %+v", s)
|
||||
}
|
||||
|
||||
// The examples cannot be changed or cleared.
|
||||
for _, n := range []string{"1", "2", "3"} {
|
||||
a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 403)
|
||||
a.do("DELETE", "/slots/"+n, nil, 403)
|
||||
}
|
||||
// Bad slot numbers and bodies.
|
||||
for _, n := range []string{"0", "17", "abc", "-1"} {
|
||||
a.do("GET", "/slots/"+n, nil, 400)
|
||||
a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 400)
|
||||
}
|
||||
a.do("PUT", "/slots/6", map[string]string{"name": " ", "source": "halt"}, 400)
|
||||
a.do("PUT", "/slots/6", map[string]string{"name": strings.Repeat("x", 41), "source": "halt"}, 400)
|
||||
a.do("PUT", "/slots/6", "not json", 400)
|
||||
a.do("PUT", "/slots/6", map[string]string{"name": "BIG", "source": strings.Repeat("x", 64<<10+1)}, 413)
|
||||
a.do("PUT", "/slots/6", map[string]string{"name": "OK", "source": strings.Repeat("x", 64<<10)}, 200)
|
||||
|
||||
// Clearing.
|
||||
a.do("DELETE", "/slots/5", nil, 204)
|
||||
a.do("DELETE", "/slots/5", nil, 204) // clearing an empty slot is fine
|
||||
if s := a.slot(5); !s.Empty {
|
||||
t.Fatalf("cleared slot = %+v", s)
|
||||
}
|
||||
|
||||
// Everything needs a key.
|
||||
anon := &player{t: t, base: srv.URL}
|
||||
anon.do("GET", "/slots", nil, 401)
|
||||
anon.do("PUT", "/slots/5", map[string]string{"name": "X", "source": "halt"}, 401)
|
||||
}
|
||||
|
||||
func TestLaunchFromSlot(t *testing.T) {
|
||||
srv, cfg := newSite(t, "")
|
||||
launch := func(p *player, slot any, want int) []byte {
|
||||
return p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]any{"slot": slot}, want)
|
||||
}
|
||||
|
||||
// A saved slot: assembled, sealed into the ship, and launched in one step.
|
||||
a := newPlayer(t, srv.URL, "ann")
|
||||
a.do("PUT", "/slots/6", map[string]string{"name": "TINY", "source": "ldi r0, 1\nhalt"}, 200)
|
||||
var res struct {
|
||||
Slot int `json:"slot"`
|
||||
ProgramBytes int `json:"program_bytes"`
|
||||
}
|
||||
json.Unmarshal(launch(a, 6, 202), &res)
|
||||
if res.Slot != 6 || res.ProgramBytes != 8 {
|
||||
t.Fatalf("launch reply = %+v", res)
|
||||
}
|
||||
if st, n := a.shipStatus(); st != "launching" || n != 8 {
|
||||
t.Fatalf("ship is %s with %d program bytes", st, n)
|
||||
}
|
||||
launch(a, 6, 409) // already launching
|
||||
|
||||
// Things that must be refused, leaving the ship docked and its program untouched.
|
||||
b := newPlayer(t, srv.URL, "bob")
|
||||
b.do("PUT", "/slots/7", map[string]string{"name": "BROKEN", "source": "ldi r1, 5\nbogus r1"}, 200)
|
||||
b.do("PUT", "/slots/8", map[string]string{"name": "HUGE", "source": strings.Repeat("nop\n", cfg.ProgramBytes/4+1)}, 200)
|
||||
b.do("PUT", "/slots/10", map[string]string{"name": "BLANK", "source": " \n"}, 200)
|
||||
for name, c := range map[string]struct {
|
||||
slot any
|
||||
msg string
|
||||
}{
|
||||
"empty slot": {9, "empty"},
|
||||
"blank source": {10, "empty"},
|
||||
"does not compile": {7, "line 2"},
|
||||
"too large": {8, "limit"},
|
||||
"out of range": {99, "1 to 16"},
|
||||
"zero": {0, "1 to 16"},
|
||||
} {
|
||||
if body := launch(b, c.slot, 400); !strings.Contains(string(body), c.msg) {
|
||||
t.Errorf("%s: error %q should mention %q", name, body, c.msg)
|
||||
}
|
||||
}
|
||||
if st, n := b.shipStatus(); st != "inventory" || n != 0 {
|
||||
t.Fatalf("a refused launch changed the ship: %s, %d bytes", st, n)
|
||||
}
|
||||
b.do("POST", "/ships/"+itoa(b.ship)+"/launch", "{not json", 400)
|
||||
b.do("POST", "/ships/"+itoa(b.ship)+"/launch", nil, 409) // no slot and no sealed program: as before
|
||||
// Slots are per player: ann's saved slot 6 is empty for bob.
|
||||
launch(b, 6, 400)
|
||||
// Ann cannot launch bob's ship with her slot.
|
||||
a.do("POST", "/ships/"+itoa(b.ship)+"/launch", map[string]int{"slot": 6}, 404)
|
||||
|
||||
// An example slot works for anyone.
|
||||
launch(b, 2, 202)
|
||||
if st, n := b.shipStatus(); st != "launching" || n == 0 {
|
||||
t.Fatalf("after launching the telemetry example: %s, %d bytes", st, n)
|
||||
}
|
||||
}
|
||||
|
||||
// A ship launched from a slot really flies that slot's program.
|
||||
func TestSlotProgramRunsInTheBelt(t *testing.T) {
|
||||
srv, cfg, st := newSiteStore(t, "")
|
||||
cfg.AsteroidCount = 20
|
||||
p := newPlayer(t, srv.URL, "ann")
|
||||
p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 2}, 202) // TELEMETRY
|
||||
|
||||
if _, err := runner.RunNextDay(context.Background(), st, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down := p.do("GET", "/ships/"+itoa(p.ship)+"/downlink", nil, 200)
|
||||
if tick := binary.LittleEndian.Uint32(down[0:4]); tick != uint32(cfg.TicksPerDay-1) {
|
||||
t.Fatalf("telemetry downlink reports tick %d, want the last tick %d", tick, cfg.TicksPerDay-1)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"wh/vm"
|
||||
"halcyon/vm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
// Command daily runs one simulated day against the database. Schedule it
|
||||
// Command daily runs one day of the belt against the database. Schedule it
|
||||
// once a day (cron/systemd timer).
|
||||
package main
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"wh/config"
|
||||
"wh/runner"
|
||||
"wh/store"
|
||||
"halcyon/config"
|
||||
"halcyon/runner"
|
||||
"halcyon/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
+14
-5
@@ -6,16 +6,18 @@ import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"wh/api"
|
||||
"wh/config"
|
||||
"wh/store"
|
||||
"wh/web"
|
||||
"halcyon/api"
|
||||
"halcyon/config"
|
||||
"halcyon/store"
|
||||
"halcyon/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := flag.String("db", config.DatabaseURL(), "sqlite:<path> or postgres:// URL (default: $DATABASE_URL)")
|
||||
addr := flag.String("addr", ":8080", "listen address")
|
||||
manual := flag.String("manual-pdf", envOr("MANUAL_PDF", "docs/generated/manual.pdf"), "rendered PDF manual to serve (default: $MANUAL_PDF)")
|
||||
cfg, err := config.Bind(flag.CommandLine)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -34,7 +36,14 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mux := api.New(st, *cfg)
|
||||
mux.Handle("/", web.Handler()) // the site; API routes are more specific and win
|
||||
mux.Handle("/", web.Handler(*manual)) // the site; API routes are more specific and win
|
||||
log.Printf("listening on %s", *addr)
|
||||
log.Fatal(http.ListenAndServe(*addr, mux))
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
+18
-18
@@ -10,7 +10,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"wh/fixed"
|
||||
"halcyon/fixed"
|
||||
)
|
||||
|
||||
const SecondsPerDay = 86400
|
||||
@@ -85,20 +85,20 @@ func Bind(fs *flag.FlagSet) (*Config, error) {
|
||||
if err := c.applyEnv(os.Getenv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fs.Uint64Var(&c.Seed, "seed", c.Seed, "world seed (env WH_SEED)")
|
||||
fs.IntVar(&c.TicksPerDay, "ticks-per-day", c.TicksPerDay, "simulation ticks per day (env WH_TICKS_PER_DAY)")
|
||||
fs.IntVar(&c.CyclesPerTick, "cycles-per-tick", c.CyclesPerTick, "VM cycles per ship per tick (env WH_CYCLES_PER_TICK)")
|
||||
fs.IntVar(&c.ProgramBytes, "program-bytes", c.ProgramBytes, "max ship program size (env WH_PROGRAM_BYTES)")
|
||||
fs.IntVar(&c.RAMBytes, "ram-bytes", c.RAMBytes, "ship RAM size (env WH_RAM_BYTES)")
|
||||
fs.IntVar(&c.CommBytes, "comm-bytes", c.CommBytes, "uplink/downlink buffer size (env WH_COMM_BYTES)")
|
||||
fs.IntVar(&c.AsteroidCount, "asteroids", c.AsteroidCount, "number of asteroids (env WH_ASTEROIDS)")
|
||||
fs.Uint64Var(&c.Seed, "seed", c.Seed, "world seed (env HALCYON_SEED)")
|
||||
fs.IntVar(&c.TicksPerDay, "ticks-per-day", c.TicksPerDay, "simulation ticks per day (env HALCYON_TICKS_PER_DAY)")
|
||||
fs.IntVar(&c.CyclesPerTick, "cycles-per-tick", c.CyclesPerTick, "VM cycles per ship per tick (env HALCYON_CYCLES_PER_TICK)")
|
||||
fs.IntVar(&c.ProgramBytes, "program-bytes", c.ProgramBytes, "max ship program size (env HALCYON_PROGRAM_BYTES)")
|
||||
fs.IntVar(&c.RAMBytes, "ram-bytes", c.RAMBytes, "ship RAM size (env HALCYON_RAM_BYTES)")
|
||||
fs.IntVar(&c.CommBytes, "comm-bytes", c.CommBytes, "uplink/downlink buffer size (env HALCYON_COMM_BYTES)")
|
||||
fs.IntVar(&c.AsteroidCount, "asteroids", c.AsteroidCount, "number of asteroids (env HALCYON_ASTEROIDS)")
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// EnvVars lists the environment variables that set the world, for documentation.
|
||||
var EnvVars = []string{
|
||||
"WH_SEED", "WH_TICKS_PER_DAY", "WH_CYCLES_PER_TICK", "WH_PROGRAM_BYTES",
|
||||
"WH_RAM_BYTES", "WH_COMM_BYTES", "WH_ASTEROIDS",
|
||||
"HALCYON_SEED", "HALCYON_TICKS_PER_DAY", "HALCYON_CYCLES_PER_TICK", "HALCYON_PROGRAM_BYTES",
|
||||
"HALCYON_RAM_BYTES", "HALCYON_COMM_BYTES", "HALCYON_ASTEROIDS",
|
||||
}
|
||||
|
||||
func (c *Config) applyEnv(get func(string) string) error {
|
||||
@@ -124,13 +124,13 @@ func (c *Config) applyEnv(get func(string) string) error {
|
||||
err = fmt.Errorf("%s=%q: %w", name, v, err)
|
||||
}
|
||||
}
|
||||
num("WH_SEED", &c.Seed)
|
||||
num("WH_TICKS_PER_DAY", &c.TicksPerDay)
|
||||
num("WH_CYCLES_PER_TICK", &c.CyclesPerTick)
|
||||
num("WH_PROGRAM_BYTES", &c.ProgramBytes)
|
||||
num("WH_RAM_BYTES", &c.RAMBytes)
|
||||
num("WH_COMM_BYTES", &c.CommBytes)
|
||||
num("WH_ASTEROIDS", &c.AsteroidCount)
|
||||
num("HALCYON_SEED", &c.Seed)
|
||||
num("HALCYON_TICKS_PER_DAY", &c.TicksPerDay)
|
||||
num("HALCYON_CYCLES_PER_TICK", &c.CyclesPerTick)
|
||||
num("HALCYON_PROGRAM_BYTES", &c.ProgramBytes)
|
||||
num("HALCYON_RAM_BYTES", &c.RAMBytes)
|
||||
num("HALCYON_COMM_BYTES", &c.CommBytes)
|
||||
num("HALCYON_ASTEROIDS", &c.AsteroidCount)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -141,5 +141,5 @@ func DatabaseURL() string {
|
||||
if v := os.Getenv("DATABASE_URL"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "sqlite:wh.db"
|
||||
return "sqlite:halcyon.db"
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ func TestBindDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBindEnvAndFlagPrecedence(t *testing.T) {
|
||||
t.Setenv("WH_SEED", "42")
|
||||
t.Setenv("WH_TICKS_PER_DAY", "720")
|
||||
t.Setenv("WH_ASTEROIDS", "60")
|
||||
t.Setenv("HALCYON_SEED", "42")
|
||||
t.Setenv("HALCYON_TICKS_PER_DAY", "720")
|
||||
t.Setenv("HALCYON_ASTEROIDS", "60")
|
||||
|
||||
c, err := bind(t)
|
||||
if err != nil {
|
||||
@@ -49,21 +49,21 @@ func TestBindEnvAndFlagPrecedence(t *testing.T) {
|
||||
|
||||
func TestBindRejectsBadEnv(t *testing.T) {
|
||||
for _, v := range []string{"lots", "-3", "1.5"} {
|
||||
t.Setenv("WH_SEED", v)
|
||||
t.Setenv("HALCYON_SEED", v)
|
||||
if _, err := bind(t); err == nil {
|
||||
t.Errorf("WH_SEED=%q should be rejected", v)
|
||||
t.Errorf("HALCYON_SEED=%q should be rejected", v)
|
||||
}
|
||||
}
|
||||
t.Setenv("WH_SEED", "")
|
||||
t.Setenv("WH_COMM_BYTES", "big")
|
||||
t.Setenv("HALCYON_SEED", "")
|
||||
t.Setenv("HALCYON_COMM_BYTES", "big")
|
||||
if _, err := bind(t); err == nil {
|
||||
t.Error("WH_COMM_BYTES=big should be rejected")
|
||||
t.Error("HALCYON_COMM_BYTES=big should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseURL(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", "")
|
||||
if DatabaseURL() != "sqlite:wh.db" {
|
||||
if DatabaseURL() != "sqlite:halcyon.db" {
|
||||
t.Fatal("default")
|
||||
}
|
||||
t.Setenv("DATABASE_URL", "postgres://x")
|
||||
|
||||
+27
-10
@@ -1,3 +1,7 @@
|
||||
# The project name is fixed (not taken from the directory), so volume and network
|
||||
# names stay stable: halcyon_data, halcyon_manual, halcyon_caddy-data, ...
|
||||
name: halcyon
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
@@ -9,21 +13,33 @@ services:
|
||||
# Local only: the public entrance is Caddy, which terminates TLS.
|
||||
- "127.0.0.1:8070:8080"
|
||||
environment:
|
||||
DATABASE_URL: sqlite:/data/wh.db
|
||||
DATABASE_URL: sqlite:/data/halcyon.db
|
||||
# The world. `docker exec ... daily` inherits these, so the daily job always
|
||||
# matches the server. A world refuses changed settings once it has run, so
|
||||
# only change them on a fresh volume. Defaults shown; override in .env.
|
||||
WH_SEED: ${WH_SEED:-1}
|
||||
WH_TICKS_PER_DAY: ${WH_TICKS_PER_DAY:-1440}
|
||||
WH_CYCLES_PER_TICK: ${WH_CYCLES_PER_TICK:-2000}
|
||||
WH_PROGRAM_BYTES: ${WH_PROGRAM_BYTES:-4096}
|
||||
WH_RAM_BYTES: ${WH_RAM_BYTES:-8192}
|
||||
WH_COMM_BYTES: ${WH_COMM_BYTES:-1024}
|
||||
WH_ASTEROIDS: ${WH_ASTEROIDS:-500}
|
||||
HALCYON_SEED: ${HALCYON_SEED:-1}
|
||||
HALCYON_TICKS_PER_DAY: ${HALCYON_TICKS_PER_DAY:-1440}
|
||||
HALCYON_CYCLES_PER_TICK: ${HALCYON_CYCLES_PER_TICK:-2000}
|
||||
HALCYON_PROGRAM_BYTES: ${HALCYON_PROGRAM_BYTES:-4096}
|
||||
HALCYON_RAM_BYTES: ${HALCYON_RAM_BYTES:-8192}
|
||||
HALCYON_COMM_BYTES: ${HALCYON_COMM_BYTES:-1024}
|
||||
HALCYON_ASTEROIDS: ${HALCYON_ASTEROIDS:-500}
|
||||
volumes:
|
||||
- halcyon-data:/data
|
||||
- data:/data
|
||||
- manual:/manual:ro # the PDF manual, rendered separately (see below)
|
||||
command: ["server"]
|
||||
|
||||
# Renders the PDF manual into the volume the web service reads. Not started by
|
||||
# `docker compose up` (that stays fast); run it with scripts/render-manual.sh.
|
||||
manual:
|
||||
build:
|
||||
context: .
|
||||
target: manual
|
||||
image: halcyon-manual:latest
|
||||
profiles: ["manual"]
|
||||
volumes:
|
||||
- manual:/out
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: halcyon-caddy
|
||||
@@ -40,6 +56,7 @@ services:
|
||||
- caddy-config:/config
|
||||
|
||||
volumes:
|
||||
halcyon-data:
|
||||
data:
|
||||
manual:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
|
||||
+9
-3
@@ -1,7 +1,13 @@
|
||||
// Package docs embeds the HC-33 programmer's manual.
|
||||
// Package docs embeds the source of the HC-33 programmer's manual.
|
||||
//
|
||||
// The PDF is not embedded: it is rendered separately (see the manual stage of
|
||||
// the Dockerfile and scripts/render-manual.sh), so that deploying the app never
|
||||
// waits for it, and is served from a file by package web.
|
||||
package docs
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed manual.txt
|
||||
var Manual []byte
|
||||
// Markdown is the manual's source, docs/manual.md.
|
||||
//
|
||||
//go:embed manual.md
|
||||
var Markdown []byte
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package docs_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"halcyon/docs"
|
||||
"halcyon/sim"
|
||||
)
|
||||
|
||||
// portValues maps the names used in the manual and in examples/programs/ports.s
|
||||
// to the simulator's real port numbers, so the documentation cannot drift.
|
||||
var portValues = map[string]int{
|
||||
"TURN": sim.PortTurn, "DAY": sim.PortDay, "TURNS": sim.PortTurns,
|
||||
"POSX": sim.PortPosX, "POSY": sim.PortPosY, "POSZ": sim.PortPosZ,
|
||||
"VELX": sim.PortVelX, "VELY": sim.PortVelY, "VELZ": sim.PortVelZ,
|
||||
"THROTTLE": sim.PortThrottle, "AZIMUTH": sim.PortAzimuth, "PITCH": sim.PortPitch,
|
||||
"FUEL": sim.PortFuel, "MASS": sim.PortMass,
|
||||
"SELECT": sim.PortScanSelect, "NEAREST": sim.PortScanNearest,
|
||||
"RELX": sim.PortScanRelX, "RELY": sim.PortScanRelY, "RELZ": sim.PortScanRelZ,
|
||||
"RELVX": sim.PortScanRelVX, "RELVY": sim.PortScanRelVY, "RELVZ": sim.PortScanRelVZ,
|
||||
"DIST": sim.PortScanDist,
|
||||
"ORE0": sim.PortScanOre, "ORE1": sim.PortScanOre + 1, "ORE2": sim.PortScanOre + 2, "ORE3": sim.PortScanOre + 3,
|
||||
"MINE": sim.PortMine, "CARGO": sim.PortCargo, "CARGOCAP": sim.PortCargoCap,
|
||||
"CARGO0": sim.PortCargoOre, "CARGO1": sim.PortCargoOre + 1, "CARGO2": sim.PortCargoOre + 2, "CARGO3": sim.PortCargoOre + 3,
|
||||
"STNX": sim.PortStnRelX, "STNY": sim.PortStnRelY, "STNZ": sim.PortStnRelZ,
|
||||
"STNVX": sim.PortStnRelVX, "STNVY": sim.PortStnRelVY, "STNVZ": sim.PortStnRelVZ,
|
||||
"SELL": sim.PortSell, "EARNED": sim.PortCredits,
|
||||
"UPNEW": sim.PortUplinkNew, "UPLEN": sim.PortUplinkLen,
|
||||
"MATHX": sim.PortMathX, "MATHY": sim.PortMathY, "MATHZ": sim.PortMathZ,
|
||||
"ATAN2": sim.PortMathAtan2, "HYPOT": sim.PortMathHypot, "NORM3": sim.PortMathNorm3,
|
||||
}
|
||||
|
||||
func section(t *testing.T, md, from, to string) string {
|
||||
t.Helper()
|
||||
a := strings.Index(md, from)
|
||||
if a < 0 {
|
||||
t.Fatalf("manual has no %q", from)
|
||||
}
|
||||
b := len(md)
|
||||
if to != "" {
|
||||
b = a + strings.Index(md[a:], to)
|
||||
}
|
||||
return md[a:b]
|
||||
}
|
||||
|
||||
func TestAppendixBMatchesSimulator(t *testing.T) {
|
||||
md := string(docs.Markdown)
|
||||
rows := regexp.MustCompile(`(?m)^0x([0-9A-F]{2})\s+(\w+)\s`).FindAllStringSubmatch(
|
||||
section(t, md, "# Appendix B", "# Appendix C"), -1)
|
||||
seen := map[string]bool{}
|
||||
for _, r := range rows {
|
||||
n, _ := strconv.ParseInt(r[1], 16, 32)
|
||||
want, ok := portValues[r[2]]
|
||||
if !ok || int(n) != want {
|
||||
t.Errorf("Appendix B: %s is 0x%s, simulator says %#x (known=%v)", r[2], r[1], want, ok)
|
||||
}
|
||||
seen[r[2]] = true
|
||||
}
|
||||
for name := range portValues {
|
||||
if !seen[name] {
|
||||
t.Errorf("Appendix B does not document port %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readExample(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("../examples/programs/" + name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.TrimRight(string(b), "\n")
|
||||
}
|
||||
|
||||
func TestEquateFileMatchesSimulatorAndManual(t *testing.T) {
|
||||
ports := readExample(t, "ports.s")
|
||||
seen := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`(?m)^\s*\.equ\s+P_(\w+)\s+0x([0-9A-F]{2})\b`).FindAllStringSubmatch(ports, -1) {
|
||||
n, _ := strconv.ParseInt(m[2], 16, 32)
|
||||
if want, ok := portValues[m[1]]; !ok || int(n) != want {
|
||||
t.Errorf("ports.s: P_%s = 0x%s, simulator says %#x (known=%v)", m[1], m[2], want, ok)
|
||||
}
|
||||
seen[m[1]] = true
|
||||
}
|
||||
for name := range portValues {
|
||||
if !seen[name] {
|
||||
t.Errorf("ports.s lacks P_%s", name)
|
||||
}
|
||||
}
|
||||
// Appendix C prints that file.
|
||||
if !strings.Contains(string(docs.Markdown), "```asm\n"+ports+"\n```") {
|
||||
t.Error("Appendix C is out of date with examples/programs/ports.s")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleProgramsMatchExamples(t *testing.T) {
|
||||
md := string(docs.Markdown)
|
||||
for _, name := range []string{"echo", "telemetry", "pursue"} {
|
||||
if !strings.Contains(md, "```asm\n"+readExample(t, name+".s")+"\n```") {
|
||||
t.Errorf("Chapter 9 listing of %s is out of date with examples/programs/%s.s", name, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1480
File diff suppressed because it is too large
Load Diff
-1556
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure how legible each page of the photocopied manual is.
|
||||
|
||||
check_legibility.py copied.pdf [crisp.pdf]
|
||||
|
||||
OCRs every page (tesseract) and scores it against the PDF's own invisible text
|
||||
layer, which is the known-correct text: the score is the share of true words
|
||||
that OCR recovers. If a crisp render is given, its score is the baseline (OCR
|
||||
is imperfect on monospace tables), and a page is judged by how much the copying
|
||||
cost it. Needs tesseract and poppler's pdftotext/pdftoppm. Development tool;
|
||||
the build does not run it.
|
||||
"""
|
||||
import difflib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def words(text):
|
||||
return re.findall(r"[A-Za-z0-9][A-Za-z0-9'.\-]*", text)
|
||||
|
||||
|
||||
def ocr_scores(pdf, dpi=130):
|
||||
"""Return {page: (recall, truth_word_count)} for each page of pdf."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
subprocess.run(["pdftoppm", "-r", str(dpi), "-gray", "-png", pdf, str(tmp / "p")], check=True)
|
||||
images = sorted(tmp.glob("p-*.png"))
|
||||
|
||||
def score(img):
|
||||
n = int(img.stem.split("-")[1])
|
||||
truth = words(subprocess.run(["pdftotext", "-f", str(n), "-l", str(n), pdf, "-"],
|
||||
capture_output=True, text=True, check=True).stdout)
|
||||
got = words(subprocess.run(["tesseract", str(img), "-", "--psm", "6"],
|
||||
capture_output=True, text=True, env={**os.environ, "OMP_THREAD_LIMIT": "1"}).stdout)
|
||||
m = difflib.SequenceMatcher(None, truth, got, autojunk=False)
|
||||
hit = sum(b.size for b in m.get_matching_blocks())
|
||||
return n, (hit / max(1, len(truth)), len(truth))
|
||||
|
||||
with ThreadPoolExecutor(4) as pool:
|
||||
return dict(pool.map(score, images))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
copied = ocr_scores(sys.argv[1])
|
||||
crisp = ocr_scores(sys.argv[2]) if len(sys.argv) > 2 else {}
|
||||
print("page words crisp copied loss")
|
||||
losses = []
|
||||
for n in sorted(copied):
|
||||
c, w = copied[n]
|
||||
b = crisp.get(n, (1.0, w))[0]
|
||||
losses.append(b - c)
|
||||
flag = " <-- check" if b - c > 0.10 else ""
|
||||
print(f"{n:4d} {w:6d} {b:5.0%} {c:5.0%} {b - c:+5.0%}{flag}")
|
||||
tot = sum(w for _, w in copied.values())
|
||||
print(f"\nmean copied recall {sum(c * w for c, w in copied.values()) / tot:.1%}"
|
||||
+ (f", mean crisp {sum(c * w for c, w in crisp.values()) / tot:.1%}" if crisp else ""))
|
||||
@@ -0,0 +1,128 @@
|
||||
/* Print stylesheet for the HC-33 manual: a typewritten reference, Letter size,
|
||||
with room on the left for the binder holes drawn by render.py. */
|
||||
|
||||
@page {
|
||||
size: Letter;
|
||||
margin: 0.95in 0.7in 0.9in 1.3in;
|
||||
|
||||
@top-left {
|
||||
content: "HC-33 FLIGHT COMPUTER";
|
||||
font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.18em; color: #4a3a26;
|
||||
border-bottom: 0.6pt solid #8a7650; padding-bottom: 3pt; vertical-align: bottom;
|
||||
}
|
||||
@top-right {
|
||||
content: string(chapter);
|
||||
font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.12em; text-transform: uppercase; color: #4a3a26;
|
||||
border-bottom: 0.6pt solid #8a7650; padding-bottom: 3pt; vertical-align: bottom;
|
||||
}
|
||||
@bottom-left {
|
||||
content: "HIC-0033-A";
|
||||
font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.18em; color: #4a3a26; vertical-align: top; padding-top: 6pt;
|
||||
}
|
||||
@bottom-right {
|
||||
content: "PAGE " counter(page);
|
||||
font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.18em; color: #4a3a26; vertical-align: top; padding-top: 6pt;
|
||||
}
|
||||
}
|
||||
|
||||
@page :first {
|
||||
margin: 0.8in 0.75in 0.7in 1.3in;
|
||||
@top-left { content: none; border: 0; }
|
||||
@top-right { content: none; border: 0; }
|
||||
@bottom-left { content: none; }
|
||||
@bottom-right { content: none; }
|
||||
}
|
||||
|
||||
html { font: 9.6pt/1.42 "Liberation Mono", "Courier New", monospace; color: #211b14; }
|
||||
body { margin: 0; }
|
||||
|
||||
p { margin: 0.55em 0; orphans: 2; widows: 2; }
|
||||
strong { font-weight: bold; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* ---- headings ---------------------------------------------------------- */
|
||||
|
||||
h1 {
|
||||
font: bold 21pt/1.15 "Liberation Sans", sans-serif; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin: 0 0 0.9em; padding: 0.3em 0;
|
||||
border-top: 3pt double #211b14; border-bottom: 3pt double #211b14;
|
||||
page-break-before: always; page-break-after: avoid; string-set: chapter content();
|
||||
}
|
||||
h1.front { page-break-before: auto; string-set: chapter "Contents"; }
|
||||
|
||||
h2 {
|
||||
font: bold 11.5pt/1.25 "Liberation Sans", sans-serif; text-transform: uppercase; letter-spacing: 0.1em;
|
||||
margin: 1.5em 0 0.55em; padding-bottom: 0.15em; border-bottom: 0.8pt dashed #6b5a3a;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: bold 9.8pt/1.3 "Liberation Mono", monospace; margin: 1.05em 0 0.15em; padding-left: 0.6em;
|
||||
border-left: 3pt solid #8a2a17; page-break-after: avoid;
|
||||
}
|
||||
|
||||
/* ---- lists, code, notes ------------------------------------------------ */
|
||||
|
||||
ul, ol { margin: 0.5em 0; padding-left: 1.7em; }
|
||||
li { margin: 0.3em 0; }
|
||||
li p { margin: 0.2em 0; }
|
||||
|
||||
code { font-family: "Liberation Mono", monospace; }
|
||||
p > code, li > code { background: rgba(120, 90, 40, 0.10); padding: 0 0.2em; }
|
||||
|
||||
pre {
|
||||
font: 8.6pt/1.3 "Liberation Mono", monospace; white-space: pre;
|
||||
margin: 0.8em 0; padding: 0.55em 0.8em;
|
||||
background: rgba(255, 251, 236, 0.5); border-left: 2.5pt solid #8b5a2b;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
pre code { background: none; padding: 0; }
|
||||
|
||||
blockquote {
|
||||
margin: 1em 0; padding: 0.5em 0.9em;
|
||||
border: 1.4pt solid #211b14; background: rgba(255, 255, 255, 0.32);
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
blockquote p { margin: 0.2em 0; }
|
||||
blockquote strong:first-child { text-transform: uppercase; letter-spacing: 0.14em; color: #8a1f11; }
|
||||
|
||||
/* ---- cover ------------------------------------------------------------- */
|
||||
|
||||
.cover { text-align: center; page-break-after: always; }
|
||||
.cover p { margin: 0; }
|
||||
.company {
|
||||
font: bold 9.5pt "Liberation Sans", sans-serif; color: #3a2a1a;
|
||||
border-top: 3pt double #3a2a1a; border-bottom: 3pt double #3a2a1a; padding: 0.55em 0;
|
||||
}
|
||||
.cover .cover-title {
|
||||
font: bold italic 35pt/1.12 "Liberation Sans", sans-serif; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
border: 0; margin: 1.35in 0 0.2in; padding: 0; page-break-before: auto; string-set: none; bookmark-level: none;
|
||||
}
|
||||
.cover-sub { font: bold 15pt "Liberation Sans", sans-serif; letter-spacing: 0.08em; text-transform: uppercase; margin-top: 0.3in !important; }
|
||||
.cover-sub2 { font-size: 10pt; margin-top: 0.1in !important; }
|
||||
.cover-box {
|
||||
width: 3.1in; margin: 0.6in auto 0.5in; padding: 0.15in 0.1in;
|
||||
border: 2.5pt solid #211b14; outline: 0.7pt solid #211b14; outline-offset: 3pt;
|
||||
font-weight: bold; font-size: 9pt;
|
||||
}
|
||||
.cover-box p { margin: 0.25em 0; }
|
||||
.pubno { font-size: 9.5pt; letter-spacing: 0.06em; }
|
||||
.cover-for { font-size: 8.6pt; margin: 0.35in 1.1in 0 !important; }
|
||||
.cover-warn {
|
||||
margin: 0.4in 0.5in 0 !important; padding: 0.14in 0; font-weight: bold; font-size: 8.4pt; letter-spacing: 0.05em;
|
||||
border-top: 0.8pt dashed #211b14; border-bottom: 0.8pt dashed #211b14; line-height: 1.6;
|
||||
}
|
||||
.cover-notice { margin: 0.32in 0 0 !important; font-size: 6.6pt; line-height: 1.35; text-align: justify; color: #3a2f22; }
|
||||
|
||||
/* ---- contents ---------------------------------------------------------- */
|
||||
|
||||
.toc ul { list-style: none; margin: 0; padding: 0; }
|
||||
.toc > ul > li { margin-top: 0.7em; font-weight: bold; font-size: 9.4pt; }
|
||||
.toc ul ul { margin-top: 0.2em; padding-left: 1.6em; }
|
||||
.toc ul ul li { font-weight: normal; font-size: 8.8pt; margin: 0.1em 0; }
|
||||
.toc a::after { content: leader(".") target-counter(attr(href), page); font-weight: normal; }
|
||||
|
||||
/* ---- end matter -------------------------------------------------------- */
|
||||
|
||||
.end { text-align: center; margin-top: 2.2em; letter-spacing: 0.2em; font-weight: bold; page-break-inside: avoid; }
|
||||
.colophon { text-align: center; font-size: 7.8pt; color: #4a3a26; letter-spacing: 0.06em; }
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Make a page image look like a photocopy of a photocopy.
|
||||
|
||||
`intensity` scales the whole effect: 1.0 is a badly worn copy of a copy, 0.5 a
|
||||
tired one, 0 leaves the image alone.
|
||||
|
||||
Each "generation" re-scans the previous copy: a little skew and shift, blur from
|
||||
the optics, a steeper toner curve, uneven toner, speckle, dust streaks down the
|
||||
page and dark shadows where the lid did not sit flat. Colour survives only as
|
||||
faint warmth, so coffee stains stay recognisable but lose their punch.
|
||||
"""
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
|
||||
def _lowfreq(rng, w, h, cells_x, cells_y, amp):
|
||||
"""Smooth random field in [-amp, amp], roughly cells_x by cells_y blobs."""
|
||||
small = rng.normal(0, 1, (cells_y + 2, cells_x + 2)).astype(np.float32)
|
||||
small = (small - small.min()) / (np.ptp(small) + 1e-6) * 2 - 1
|
||||
img = Image.fromarray(small, mode="F").resize((w, h), Image.BICUBIC)
|
||||
return np.asarray(img, dtype=np.float32) * amp
|
||||
|
||||
|
||||
def _speckles(rng, w, h, count, rmin, rmax):
|
||||
mask = Image.new("L", (w, h), 0)
|
||||
d = ImageDraw.Draw(mask)
|
||||
count = max(0, count)
|
||||
for _ in range(count):
|
||||
x, y = rng.uniform(0, w), rng.uniform(0, h)
|
||||
r = rng.uniform(rmin, rmax)
|
||||
d.ellipse((x - r, y - r, x + r, y + r), fill=int(rng.uniform(120, 255)))
|
||||
return np.asarray(mask.filter(ImageFilter.GaussianBlur(0.5)), dtype=np.float32) / 255.0
|
||||
|
||||
|
||||
def _streaks(rng, arr, gen, t):
|
||||
h, w = arr.shape
|
||||
rows = np.arange(h)
|
||||
for _ in range(int(rng.integers(2, 5 + gen))):
|
||||
x0 = rng.uniform(0, w)
|
||||
xs = x0 + np.cumsum(rng.normal(0, 0.12, h)) # the streak wanders a little
|
||||
width = int(rng.integers(1, 4))
|
||||
strength = rng.uniform(0.03, 0.13) * t
|
||||
# dust comes and goes along the page
|
||||
fade = 0.55 + 0.45 * np.sin(rows / rng.uniform(60, 400) + rng.uniform(0, 6.28))
|
||||
fade *= np.clip(_1d(rng, h) + 0.6, 0, 1)
|
||||
for dx in range(width):
|
||||
cols = np.clip(xs.astype(int) + dx, 0, w - 1)
|
||||
arr[rows, cols] -= strength * fade * (1.0 if dx == 0 else 0.55)
|
||||
# one broad, faint band where the roller was tired
|
||||
x0 = int(rng.uniform(0.1, 0.9) * w)
|
||||
arr[:, x0:x0 + int(rng.uniform(20, 60))] *= 1 - (1 - rng.uniform(0.985, 0.997)) * t
|
||||
|
||||
|
||||
def _1d(rng, n):
|
||||
pts = rng.normal(0, 0.5, n // 40 + 3).astype(np.float32)
|
||||
return np.interp(np.arange(n), np.linspace(0, n - 1, len(pts)), pts)
|
||||
|
||||
|
||||
def _edge_shadows(rng, arr, t):
|
||||
h, w = arr.shape
|
||||
for edge in rng.permutation(4)[: int(rng.integers(1, 3))]:
|
||||
depth = int(rng.uniform(8, 26))
|
||||
strength = rng.uniform(0.18, 0.42) * t
|
||||
ramp = np.linspace(strength, 0, depth, dtype=np.float32) ** 1.6
|
||||
jag = 1 + 0.35 * _1d(rng, max(h, w))
|
||||
if edge == 0:
|
||||
arr[:, :depth] -= ramp[None, :] * jag[:h, None]
|
||||
elif edge == 1:
|
||||
arr[:, w - depth:] -= ramp[::-1][None, :] * jag[:h, None]
|
||||
elif edge == 2:
|
||||
arr[:depth, :] -= ramp[:, None] * jag[None, :w]
|
||||
else:
|
||||
arr[h - depth:, :] -= ramp[::-1][:, None] * jag[None, :w]
|
||||
|
||||
|
||||
def _generation(rng, luma, gen, t):
|
||||
"""One trip through the copier. luma is a float array in [0, 1]."""
|
||||
h, w = luma.shape
|
||||
im = Image.fromarray((np.clip(luma, 0, 1) * 255).astype(np.uint8), "L")
|
||||
|
||||
# placed slightly crooked on the glass; the lid backing shows in the wedges
|
||||
im = im.rotate(rng.uniform(-0.28, 0.28) * t, resample=Image.BICUBIC, fillcolor=70,
|
||||
translate=(rng.uniform(-4, 4) * t, rng.uniform(-4, 4) * t))
|
||||
# toner spreads: thin strokes fatten a little on every pass
|
||||
fat = np.asarray(im.filter(ImageFilter.MinFilter(3)), dtype=np.float32) / 255.0
|
||||
thin = np.asarray(im, dtype=np.float32) / 255.0
|
||||
im = Image.fromarray((np.clip((1 - 0.14 * t) * thin + 0.14 * t * fat, 0, 1) * 255).astype(np.uint8), "L")
|
||||
if t > 0:
|
||||
im = im.filter(ImageFilter.GaussianBlur(t * (0.55 + 0.05 * gen))) # optics
|
||||
a = np.asarray(im, dtype=np.float32) / 255.0
|
||||
|
||||
# a faint ghost from the previous pass being a hair out of register
|
||||
dy, dx = int(rng.integers(-2, 3)), int(rng.integers(-2, 3))
|
||||
if dx or dy:
|
||||
a = (1 - 0.12 * t) * a + 0.12 * t * np.roll(a, (dy, dx), axis=(0, 1))
|
||||
|
||||
# toner curve: blacker blacks, paler paper, but keep mid-tones for stains
|
||||
k, c = 4.6 + 0.4 * gen, 0.50
|
||||
s = lambda x: 1 / (1 + np.exp(-k * (x - c)))
|
||||
a = (1 - t) * a + t * (s(a) - s(0.0)) / (s(1.0) - s(0.0))
|
||||
|
||||
# uneven toner and grain
|
||||
a *= 1 + _lowfreq(rng, w, h, 5, 7, 0.022 * t) + _lowfreq(rng, w, h, w // 45, h // 45, 0.014 * t)
|
||||
a += rng.normal(0, 0.018 * t, a.shape).astype(np.float32)
|
||||
|
||||
# dust and toner specks; a few white dropouts inside the ink
|
||||
a = np.minimum(a, 1 - 0.65 * _speckles(rng, w, h, int((150 + 90 * gen) * t), 0.5, 1.5))
|
||||
a = np.maximum(a, 0.85 * _speckles(rng, w, h, int(110 * t), 0.5, 1.3) * (a < 0.35))
|
||||
|
||||
_streaks(rng, a, gen, t)
|
||||
_edge_shadows(rng, a, t)
|
||||
return np.clip(a, 0, 1)
|
||||
|
||||
|
||||
def photocopy(img, seed, copies=3, intensity=1.0):
|
||||
"""Return a degraded RGB copy of `img` (an RGB PIL image)."""
|
||||
rng = np.random.default_rng(seed)
|
||||
ycc = img.convert("YCbCr")
|
||||
luma = np.asarray(ycc.split()[0], dtype=np.float32) / 255.0
|
||||
for g in range(copies):
|
||||
luma = _generation(rng, luma, g, intensity)
|
||||
|
||||
# keep only a whisper of the original colour, smeared
|
||||
_, cb, cr = ycc.split()
|
||||
soften = lambda ch: ch.filter(ImageFilter.GaussianBlur(5)).point(lambda v: 128 + (v - 128) * (1 - 0.15 * intensity))
|
||||
y = Image.fromarray((luma * 255).astype(np.uint8), "L")
|
||||
return Image.merge("YCbCr", (y, soften(cb), soften(cr))).convert("RGB")
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the HC-33 manual (Markdown) to a PDF that looks like a well-used
|
||||
copy from a ring binder: yellowed paper, three punched holes and coffee rings.
|
||||
|
||||
render.py manual.md manual.pdf [--copies 3] [--intensity 0.5] [--dpi 130]
|
||||
|
||||
The look is generated (no image assets) and deterministic: the same input
|
||||
always produces the same stains and dust. Needs the `markdown`, `weasyprint`,
|
||||
`pillow` and `numpy` packages, poppler's pdftoppm, and the Liberation fonts.
|
||||
|
||||
--copies N photocopies the pages N times over. The pages are rasterised, run
|
||||
through the copier model in photocopy.py, and used as page backgrounds
|
||||
under invisible live text, so search, selection, links and bookmarks still work.
|
||||
--copies 0 leaves the crisp vector pages. --intensity scales how worn each copy
|
||||
looks (1.0 = badly, 0.5 = default, 0 = clean scans).
|
||||
"""
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import random
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import markdown
|
||||
from weasyprint import CSS, HTML
|
||||
|
||||
import photocopy
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
W, H = 816, 1056 # US Letter at 96 dpi
|
||||
|
||||
|
||||
# ---- page backgrounds --------------------------------------------------------
|
||||
|
||||
def ring_path(cx, cy, r, rng, wobble=0.05):
|
||||
"""A slightly irregular closed outline around (cx, cy)."""
|
||||
phases = [rng.uniform(0, 2 * math.pi) for _ in range(4)]
|
||||
amps = [rng.uniform(0.01, wobble) for _ in range(4)]
|
||||
pts = []
|
||||
for i in range(80):
|
||||
t = 2 * math.pi * i / 80
|
||||
rr = r * (1 + sum(a * math.sin((k + 2) * t + p) for k, (a, p) in enumerate(zip(amps, phases))))
|
||||
pts.append((cx + rr * math.cos(t), cy + rr * math.sin(t) * 0.97))
|
||||
return "M" + " L".join(f"{x:.1f},{y:.1f}" for x, y in pts) + " Z"
|
||||
|
||||
|
||||
def coffee_ring(uid, cx, cy, r, rng, strength=1.0):
|
||||
"""A cup ring: a pale wash, a dark uneven rim, a halo, and a few splashes."""
|
||||
out = []
|
||||
a = strength
|
||||
out.append(
|
||||
f'<radialGradient id="wash{uid}" cx="50%" cy="50%" r="50%">'
|
||||
f'<stop offset="0" stop-color="#a8712f" stop-opacity="{0.02 * a:.3f}"/>'
|
||||
f'<stop offset="0.75" stop-color="#a8712f" stop-opacity="{0.07 * a:.3f}"/>'
|
||||
f'<stop offset="1" stop-color="#8c5a22" stop-opacity="{0.16 * a:.3f}"/></radialGradient>'
|
||||
)
|
||||
body = []
|
||||
outline = ring_path(cx, cy, r, rng)
|
||||
body.append(f'<path d="{outline}" fill="url(#wash{uid})"/>')
|
||||
# soft halo just outside the rim
|
||||
body.append(f'<path d="{ring_path(cx, cy, r * 1.03, rng)}" fill="none" stroke="#a8742f" stroke-width="9" opacity="{0.05 * a:.3f}"/>')
|
||||
body.append(f'<path d="{ring_path(cx, cy, r * 1.01, rng)}" fill="none" stroke="#a8742f" stroke-width="5" opacity="{0.09 * a:.3f}"/>')
|
||||
# the rim itself: thin all round, thicker and darker in places
|
||||
body.append(f'<path d="{outline}" fill="none" stroke="#7b4a1c" stroke-width="2.2" opacity="{0.34 * a:.3f}"/>')
|
||||
dash = " ".join(f"{rng.uniform(60, 240):.0f} {rng.uniform(30, 120):.0f}" for _ in range(3))
|
||||
body.append(f'<path d="{outline}" fill="none" stroke="#5f3510" stroke-width="3.6" stroke-dasharray="{dash}" opacity="{0.36 * a:.3f}"/>')
|
||||
# splashes
|
||||
for _ in range(rng.randint(4, 10)):
|
||||
ang = rng.uniform(0, 2 * math.pi)
|
||||
dist = r * rng.uniform(1.06, 1.45)
|
||||
body.append(
|
||||
f'<circle cx="{cx + dist * math.cos(ang):.1f}" cy="{cy + dist * math.sin(ang):.1f}" '
|
||||
f'r="{rng.uniform(0.8, 4.2):.1f}" fill="#6b3f18" opacity="{rng.uniform(0.12, 0.34) * a:.3f}"/>'
|
||||
)
|
||||
return "".join(out), "".join(body)
|
||||
|
||||
|
||||
def hole(cx, cy, reinforced, cracked, rng):
|
||||
parts = []
|
||||
if reinforced: # a stick-on paper reinforcement ring
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="20.5" fill="none" stroke="#000" stroke-width="10" opacity="0.06"/>')
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="19" fill="none" stroke="#f8f2df" stroke-width="10"/>')
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="24" fill="none" stroke="#cdbf90" stroke-width="0.8" opacity="0.8"/>')
|
||||
parts.append(f'<circle cx="{cx + 1}" cy="{cy + 1.4}" r="15.5" fill="#000" opacity="0.10"/>') # soft shadow
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="13.6" fill="none" stroke="#fffaf0" stroke-width="1.6" opacity="0.85"/>') # cut edge
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="12.4" fill="#2a1f14"/>')
|
||||
parts.append(f'<circle cx="{cx + 1.6}" cy="{cy + 1.8}" r="9.6" fill="#4a3a29" opacity="0.75"/>') # lit far wall
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="12.4" fill="none" stroke="#000" stroke-width="1.4" opacity="0.45"/>')
|
||||
if cracked: # a small tear running out from the hole
|
||||
ang = rng.uniform(-0.5, 0.5)
|
||||
ln = rng.uniform(9, 20)
|
||||
x0, y0 = cx + 13 * math.cos(ang), cy + 13 * math.sin(ang)
|
||||
parts.append(f'<path d="M{x0:.1f},{y0:.1f} l{ln * math.cos(ang):.1f},{ln * math.sin(ang) + rng.uniform(-2, 2):.1f}" stroke="#6b5a3a" stroke-width="0.9" opacity="0.55" fill="none"/>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
STAIN_ANCHORS = [(705, 925), (690, 130), (170, 985), (430, 1030), (790, 520), (640, 640), (300, 60)]
|
||||
|
||||
|
||||
def page_svg(n, cover=False, boost=1.0):
|
||||
rng = random.Random(4242 + n * 7919)
|
||||
defs = []
|
||||
layers = [f'<rect width="{W}" height="{H}" fill="#f2e9cf"/>']
|
||||
|
||||
# broad yellowing blotches, built from stacked translucent discs
|
||||
for _ in range(5):
|
||||
cx, cy, r = rng.uniform(0, W), rng.uniform(0, H), rng.uniform(150, 340)
|
||||
for k in range(18):
|
||||
layers.append(f'<circle cx="{cx:.0f}" cy="{cy:.0f}" r="{r * (1 - k / 18):.0f}" fill="#d9c17a" opacity="0.016"/>')
|
||||
|
||||
# aged edges: nested frames, darker towards the sheet edge
|
||||
for k in range(48):
|
||||
inset = k * 3.6 + 1.8
|
||||
layers.append(f'<rect x="{inset}" y="{inset}" width="{W - 2 * inset}" height="{H - 2 * inset}" fill="none" '
|
||||
f'stroke="#b08840" stroke-width="3.6" opacity="{0.20 * (1 - k / 48) ** 2.6:.3f}"/>')
|
||||
|
||||
# paper fibres and foxing
|
||||
fib = []
|
||||
for _ in range(260):
|
||||
x, y, a, ln = rng.uniform(0, W), rng.uniform(0, H), rng.uniform(0, math.pi), rng.uniform(5, 22)
|
||||
fib.append(f'M{x:.0f},{y:.0f}l{ln * math.cos(a):.1f},{ln * math.sin(a):.1f}')
|
||||
layers.append(f'<path d="{"".join(fib)}" stroke="#8a7448" stroke-width="0.6" opacity="0.10" fill="none"/>')
|
||||
for _ in range(170):
|
||||
layers.append(f'<circle cx="{rng.uniform(0, W):.0f}" cy="{rng.uniform(0, H):.0f}" r="{rng.uniform(0.4, 1.9):.1f}" fill="#7a5426" opacity="{rng.uniform(0.04, 0.2):.2f}"/>')
|
||||
|
||||
for k in range(40): # binder-side shading fading over 80 px
|
||||
layers.append(f'<rect x="{k * 2}" width="2" height="{H}" fill="#5b4526" opacity="{0.17 * (1 - k / 40) ** 1.6:.3f}"/>')
|
||||
|
||||
# coffee
|
||||
if cover:
|
||||
# The cover is mostly text, so the cups go in the empty corners: one off the
|
||||
# bottom-right edge, a small one beside the binder holes, a stray at top right.
|
||||
stains = [(700, 1052, 92, 1.15), (100, 655, 50, 0.9), (745, 150, 44, 0.8)]
|
||||
else:
|
||||
stains = []
|
||||
if rng.random() < 0.38:
|
||||
ax, ay = rng.choice(STAIN_ANCHORS)
|
||||
stains.append((ax + rng.uniform(-40, 40), ay + rng.uniform(-40, 40), rng.uniform(52, 98), rng.uniform(0.7, 1.15)))
|
||||
if rng.random() < 0.3: # the cup was put down twice
|
||||
stains.append((stains[0][0] + rng.uniform(-40, 40), stains[0][1] + rng.uniform(25, 50), stains[0][2] * 0.75, 0.75))
|
||||
for i, (cx, cy, r, s) in enumerate(stains):
|
||||
d, body = coffee_ring(f"{n}_{i}", cx, cy, r, rng, s * boost)
|
||||
defs.append(d)
|
||||
layers.append(body)
|
||||
|
||||
# binder holes, US three-hole spacing (4.25 in), centred 0.5 in from the edge
|
||||
for cy in (120, 528, 936):
|
||||
layers.append(hole(48, cy, reinforced=(n % 3 == 0), cracked=(rng.random() < 0.12), rng=rng))
|
||||
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">'
|
||||
f'<defs>{"".join(defs)}</defs>{"".join(layers)}</svg>')
|
||||
|
||||
|
||||
# ---- document ----------------------------------------------------------------
|
||||
|
||||
def build_html(md_text):
|
||||
body = markdown.markdown(
|
||||
md_text,
|
||||
extensions=["toc", "fenced_code", "md_in_html", "sane_lists"],
|
||||
extension_configs={"toc": {"toc_depth": "1-2"}},
|
||||
)
|
||||
return (
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
||||
"<title>HC-33 Flight Computer: Programmer's Reference Manual</title>"
|
||||
'<meta name="author" content="Halcyon Instrument & Control">'
|
||||
'<meta name="description" content="Publication HIC-0033-A, first edition">'
|
||||
f"</head><body>{body}</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _copy_page(job):
|
||||
from PIL import Image
|
||||
src, dst, seed, copies, dpi, intensity = job
|
||||
photocopy.photocopy(Image.open(src).convert("RGB"), seed=seed, copies=copies, intensity=intensity).save(
|
||||
dst, quality=58, optimize=True, progressive=True, dpi=(dpi, dpi))
|
||||
|
||||
|
||||
INVISIBLE = """
|
||||
html *, html *::before, html *::after {
|
||||
color: rgba(0, 0, 0, 0.01) !important; background: none !important;
|
||||
border-color: transparent !important; outline: 0 !important; text-shadow: none !important;
|
||||
}
|
||||
@page {
|
||||
@top-left { color: rgba(0, 0, 0, 0.01); border-color: transparent; }
|
||||
@top-right { color: rgba(0, 0, 0, 0.01); border-color: transparent; }
|
||||
@bottom-left { color: rgba(0, 0, 0, 0.01); }
|
||||
@bottom-right { color: rgba(0, 0, 0, 0.01); }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def page_rules(image_for, pages):
|
||||
rules = []
|
||||
for n in range(1, pages + 1):
|
||||
# WeasyPrint positions a page background against the content box, so
|
||||
# shift it back by that page's margins to cover the whole sheet.
|
||||
top = "-0.8in" if n == 1 else "-0.95in"
|
||||
rules.append(f"@page :nth({n}) {{ background: #f2e9cf url({image_for(n)}) no-repeat -1.3in {top} / 8.5in 11in; }}")
|
||||
return "\n".join(rules)
|
||||
|
||||
|
||||
def render(md_path, pdf_path, copies=3, dpi=130, intensity=0.5):
|
||||
html = build_html(pathlib.Path(md_path).read_text(encoding="utf-8"))
|
||||
base_css = CSS(filename=str(HERE / "manual.css"))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = pathlib.Path(tmp)
|
||||
doc = HTML(string=html, base_url=str(tmp))
|
||||
pages = len(doc.render(stylesheets=[base_css]).pages) # backgrounds do not affect layout
|
||||
for n in range(1, pages + 1):
|
||||
(tmp / f"bg-{n}.svg").write_text(page_svg(n, cover=(n == 1), boost=1.0 if copies <= 0 else 1.0 + 1.4 * intensity), encoding="utf-8")
|
||||
bg_css = CSS(string=page_rules(lambda n: f"bg-{n}.svg", pages), base_url=str(tmp))
|
||||
|
||||
out_tmp = f"{pdf_path}.tmp" # written beside the target, then renamed into place
|
||||
|
||||
def publish():
|
||||
os.replace(out_tmp, pdf_path)
|
||||
|
||||
if copies <= 0:
|
||||
doc.render(stylesheets=[base_css, bg_css]).write_pdf(out_tmp)
|
||||
publish()
|
||||
return pages
|
||||
|
||||
# 1. the crisp original, 2. rasterise it, 3. photocopy the rasters
|
||||
doc.render(stylesheets=[base_css, bg_css]).write_pdf(tmp / "crisp.pdf")
|
||||
subprocess.run(["pdftoppm", "-r", str(dpi), "-png", str(tmp / "crisp.pdf"), str(tmp / "page")], check=True)
|
||||
width = len(str(pages))
|
||||
jobs = [(tmp / f"page-{n:0{width}d}.png", tmp / f"scan-{n}.jpg", 9000 + n, copies, dpi, intensity) for n in range(1, pages + 1)]
|
||||
with concurrent.futures.ProcessPoolExecutor() as pool: # pages are independent
|
||||
list(pool.map(_copy_page, jobs))
|
||||
|
||||
# 4. the same document again, but the copies are the paper and the live text is invisible
|
||||
scan_css = CSS(string=page_rules(lambda n: f"scan-{n}.jpg", pages) + INVISIBLE, base_url=str(tmp))
|
||||
doc.render(stylesheets=[base_css, scan_css]).write_pdf(out_tmp)
|
||||
publish()
|
||||
return pages
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description="Render the HC-33 manual to a battered PDF.")
|
||||
ap.add_argument("markdown")
|
||||
ap.add_argument("pdf")
|
||||
ap.add_argument("--copies", type=int, default=3, help="photocopy generations (0 = crisp)")
|
||||
ap.add_argument("--intensity", type=float, default=0.5, help="how worn the copies look (1.0 = heavy)")
|
||||
ap.add_argument("--dpi", type=int, default=130, help="resolution of the copied pages")
|
||||
args = ap.parse_args()
|
||||
print(f"rendered {render(args.markdown, args.pdf, args.copies, args.dpi, args.intensity)} pages -> {args.pdf}")
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"wh/config"
|
||||
"wh/fixed"
|
||||
"wh/sim"
|
||||
"wh/vm"
|
||||
"halcyon/config"
|
||||
"halcyon/fixed"
|
||||
"halcyon/sim"
|
||||
"halcyon/vm"
|
||||
)
|
||||
|
||||
func asm(t *testing.T, name string) []byte {
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
.equ TX 1024 ; downlink buffer
|
||||
.equ RAMTOP 8192 ; initial stack pointer
|
||||
|
||||
.equ P_TICK 0x00
|
||||
.equ P_TURN 0x00
|
||||
.equ P_DAY 0x01
|
||||
.equ P_TICKS 0x02
|
||||
.equ P_TURNS 0x02
|
||||
.equ P_POSX 0x10
|
||||
.equ P_POSY 0x11
|
||||
.equ P_POSZ 0x12
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
; TELEMETRY -- every tick, write tick number, fuel and position to the
|
||||
; TELEMETRY -- every turn, write turn number, fuel and position to the
|
||||
; downlink buffer as five 32-bit words.
|
||||
;
|
||||
.equ P_TICK 0x00
|
||||
.equ P_TURN 0x00
|
||||
.equ P_POSX 0x10
|
||||
.equ P_POSY 0x11
|
||||
.equ P_POSZ 0x12
|
||||
.equ P_FUEL 0x23
|
||||
.equ T_TICK 1024 ; TX + 0
|
||||
.equ T_TURN 1024 ; TX + 0
|
||||
.equ T_FUEL 1028 ; TX + 4
|
||||
.equ T_X 1032 ; TX + 8
|
||||
.equ T_Y 1036 ; TX + 12
|
||||
.equ T_Z 1040 ; TX + 16
|
||||
|
||||
ldi r0, 0 ; r0 = 0, the base register
|
||||
loop: in r1, P_TICK
|
||||
stw r1, [r0+T_TICK]
|
||||
loop: in r1, P_TURN
|
||||
stw r1, [r0+T_TURN]
|
||||
in r1, P_FUEL
|
||||
stw r1, [r0+T_FUEL]
|
||||
in r1, P_POSX
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
// (credits per kg) that fall as supply accumulates and recover over time.
|
||||
package market
|
||||
|
||||
import "wh/world"
|
||||
import "halcyon/world"
|
||||
|
||||
var basePrice = [world.NumOre]int64{world.Iron: 2, world.Nickel: 6, world.Ice: 3, world.Platinum: 300}
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ package rng
|
||||
import (
|
||||
"math/bits"
|
||||
|
||||
"wh/fixed"
|
||||
"halcyon/fixed"
|
||||
)
|
||||
|
||||
type R struct{ s [4]uint64 }
|
||||
|
||||
+3
-3
@@ -7,9 +7,9 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"wh/config"
|
||||
"wh/sim"
|
||||
"wh/store"
|
||||
"halcyon/config"
|
||||
"halcyon/sim"
|
||||
"halcyon/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run one simulated day inside the running container.
|
||||
# Run one day of the belt inside the running container.
|
||||
#
|
||||
# scripts/daily.sh [daily flags...]
|
||||
#
|
||||
@@ -9,7 +9,7 @@
|
||||
# Environment:
|
||||
# HALCYON_CONTAINER container name (default: halcyon, as in docker-compose.yml)
|
||||
#
|
||||
# The world settings (WH_SEED, WH_ASTEROIDS, ...) come from the container's
|
||||
# The world settings (HALCYON_SEED, HALCYON_ASTEROIDS, ...) come from the container's
|
||||
# environment, so the daily run always matches the server.
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render docs/manual.md to docs/generated/manual.pdf, for a locally run server
|
||||
# (go run ./cmd/server serves that path at /manual.pdf by default). Needs Docker.
|
||||
# The compose deployment uses scripts/render-manual.sh instead.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p docs/generated
|
||||
docker build --quiet --target manual -t halcyon-manual .
|
||||
docker run --rm -v "$PWD/docs/generated:/out" halcyon-manual
|
||||
echo "wrote docs/generated/manual.pdf"
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render docs/manual.md to the PDF served at /manual.pdf, without touching the
|
||||
# running app: the renderer writes into the volume the web container reads, and
|
||||
# the new file is picked up on the next request (no restart).
|
||||
#
|
||||
# Takes a couple of minutes; run it in the background after a deploy:
|
||||
# scripts/render-manual.sh > /tmp/render-manual.log 2>&1 &
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) rendering the manual..."
|
||||
docker compose --profile manual build --quiet manual
|
||||
docker compose --profile manual run --rm manual
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) done: /manual.pdf now serves the new render"
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"wh/config"
|
||||
"wh/fixed"
|
||||
"wh/world"
|
||||
"halcyon/config"
|
||||
"halcyon/fixed"
|
||||
"halcyon/world"
|
||||
)
|
||||
|
||||
// tickCtx is per-tick shared context; asteroid positions are computed lazily
|
||||
@@ -81,11 +81,11 @@ func axis(v fixed.Vec, port, base uint16, conv func(fixed.F) int32) int32 {
|
||||
func (b *shipBus) In(port uint16) int32 {
|
||||
sh, tc := b.sh, b.tc
|
||||
switch {
|
||||
case port == PortTick:
|
||||
case port == PortTurn:
|
||||
return int32(tc.tick)
|
||||
case port == PortDay:
|
||||
return sat32(tc.s.Day)
|
||||
case port == PortTicks:
|
||||
case port == PortTurns:
|
||||
return int32(tc.cfg.TicksPerDay)
|
||||
case port >= PortPosX && port <= PortPosZ:
|
||||
return axis(sh.Pos, port, PortPosX, kmInt)
|
||||
|
||||
+2
-2
@@ -6,9 +6,9 @@ package sim
|
||||
// +X towards +Y and elevation (pitch) up from the XY plane towards +Z.
|
||||
const (
|
||||
// System.
|
||||
PortTick = 0x00 // in: tick within the current day
|
||||
PortTurn = 0x00 // in: turn within the current day
|
||||
PortDay = 0x01 // in: day number
|
||||
PortTicks = 0x02 // in: ticks per day
|
||||
PortTurns = 0x02 // in: turns per day
|
||||
|
||||
// Navigation (relative to the star).
|
||||
PortPosX = 0x10
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"wh/config"
|
||||
"wh/fixed"
|
||||
"wh/vm"
|
||||
"halcyon/config"
|
||||
"halcyon/fixed"
|
||||
"halcyon/vm"
|
||||
)
|
||||
|
||||
func testCfg() config.Config {
|
||||
|
||||
+5
-5
@@ -5,11 +5,11 @@ package sim
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"wh/config"
|
||||
"wh/fixed"
|
||||
"wh/market"
|
||||
"wh/vm"
|
||||
"wh/world"
|
||||
"halcyon/config"
|
||||
"halcyon/fixed"
|
||||
"halcyon/market"
|
||||
"halcyon/vm"
|
||||
"halcyon/world"
|
||||
)
|
||||
|
||||
// Hull describes the physical properties of a ship design.
|
||||
|
||||
+4
-4
@@ -4,10 +4,10 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"wh/config"
|
||||
"wh/fixed"
|
||||
"wh/vm"
|
||||
"wh/world"
|
||||
"halcyon/config"
|
||||
"halcyon/fixed"
|
||||
"halcyon/vm"
|
||||
"halcyon/world"
|
||||
)
|
||||
|
||||
// RunDay simulates one full day, mutating the state, and returns the result.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Package slots defines the sixteen code slots each player has. Slots 1-3 hold
|
||||
// the built-in example programs and are read-only; slots 4-16 belong to the
|
||||
// player, who saves source code in them and picks one when launching a ship.
|
||||
package slots
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
"halcyon/examples"
|
||||
)
|
||||
|
||||
const Count = 16
|
||||
|
||||
// builtin lists the example programs, in slot order (slot 1 is the first).
|
||||
var builtin = []string{"echo", "telemetry", "pursue"}
|
||||
|
||||
// FirstUser is the first slot a player can write to.
|
||||
var FirstUser = len(builtin) + 1
|
||||
|
||||
type Slot struct {
|
||||
Number int
|
||||
Name string
|
||||
Source string
|
||||
Builtin bool
|
||||
}
|
||||
|
||||
// Valid reports whether n is one of the sixteen slots.
|
||||
func Valid(n int) bool { return n >= 1 && n <= Count }
|
||||
|
||||
// Builtin returns the example that occupies slot n, if it is one of the first three.
|
||||
func Builtin(n int) (Slot, bool) {
|
||||
if n < 1 || n > len(builtin) {
|
||||
return Slot{}, false
|
||||
}
|
||||
src, err := fs.ReadFile(examples.FS, builtin[n-1]+".s")
|
||||
if err != nil {
|
||||
return Slot{}, false
|
||||
}
|
||||
return Slot{Number: n, Name: strings.ToUpper(builtin[n-1]), Source: string(src), Builtin: true}, true
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"wh/sim"
|
||||
"halcyon/sim"
|
||||
)
|
||||
|
||||
// Ship statuses.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// CodeSlot is a player's saved source code.
|
||||
type CodeSlot struct {
|
||||
Slot int
|
||||
Name string
|
||||
Source string
|
||||
}
|
||||
|
||||
// ListCodeSlots returns the names of a player's saved slots, keyed by slot number.
|
||||
func (q *Q) ListCodeSlots(ctx context.Context, owner int64) (map[int]string, error) {
|
||||
rows, err := q.query(ctx, `SELECT slot, name FROM code_slots WHERE player_id = ?`, owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int]string{}
|
||||
for rows.Next() {
|
||||
var n int
|
||||
var name string
|
||||
if err := rows.Scan(&n, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[n] = name
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetCodeSlot returns one saved slot, or ErrNotFound if it is empty.
|
||||
func (q *Q) GetCodeSlot(ctx context.Context, owner int64, slot int) (CodeSlot, error) {
|
||||
cs := CodeSlot{Slot: slot}
|
||||
err := q.row(ctx, `SELECT name, source FROM code_slots WHERE player_id = ? AND slot = ?`, owner, slot).Scan(&cs.Name, &cs.Source)
|
||||
if err == sql.ErrNoRows {
|
||||
return cs, ErrNotFound
|
||||
}
|
||||
return cs, err
|
||||
}
|
||||
|
||||
// PutCodeSlot saves source in a slot, replacing whatever was there.
|
||||
func (q *Q) PutCodeSlot(ctx context.Context, owner int64, slot int, name, source, at string) error {
|
||||
_, err := q.exec(ctx, `INSERT INTO code_slots (player_id, slot, name, source, updated_at) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (player_id, slot) DO UPDATE SET name = excluded.name, source = excluded.source, updated_at = excluded.updated_at`,
|
||||
owner, slot, name, source, at)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCodeSlot empties a slot. Emptying an empty slot is not an error.
|
||||
func (q *Q) DeleteCodeSlot(ctx context.Context, owner int64, slot int) error {
|
||||
_, err := q.exec(ctx, `DELETE FROM code_slots WHERE player_id = ? AND slot = ?`, owner, slot)
|
||||
return err
|
||||
}
|
||||
|
||||
// LaunchWithProgram seals prog into a docked ship and queues it for launch in
|
||||
// one step, replacing any program it already had.
|
||||
func (q *Q) LaunchWithProgram(ctx context.Context, owner, ship int64, prog []byte) error {
|
||||
if _, err := q.ownedStatus(ctx, owner, ship); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := q.exec(ctx, `UPDATE ships SET program = ?, status = ? WHERE id = ? AND owner_id = ? AND status = ?`,
|
||||
prog, StatusLaunching, ship, owner, StatusInventory)
|
||||
return q.requireOneRow(res, err)
|
||||
}
|
||||
@@ -123,6 +123,10 @@ var schema = []string{
|
||||
id {ID}, owner_id BIGINT NOT NULL REFERENCES players(id),
|
||||
status TEXT NOT NULL, program {BLOB}, uplink {BLOB}, downlink {BLOB},
|
||||
downlink_day BIGINT NOT NULL DEFAULT -1)`,
|
||||
`CREATE TABLE IF NOT EXISTS code_slots (
|
||||
player_id BIGINT NOT NULL REFERENCES players(id), slot INTEGER NOT NULL,
|
||||
name TEXT NOT NULL, source TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (player_id, slot))`,
|
||||
`CREATE INDEX IF NOT EXISTS ships_owner ON ships(owner_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS ships_status ON ships(status)`,
|
||||
`CREATE TABLE IF NOT EXISTS snapshots (
|
||||
|
||||
+22
-7
@@ -1,5 +1,20 @@
|
||||
// Client for the belt's HTTP API.
|
||||
|
||||
/**
|
||||
* The project used to be called "wh". Carry over anything an earlier visit stored
|
||||
* under the old prefix (the access key, drafts, choices), so nobody is logged out.
|
||||
*/
|
||||
export function migrateStorage() {
|
||||
try {
|
||||
for (const k of Object.keys(localStorage)) {
|
||||
if (!k.startsWith('wh.')) continue;
|
||||
const next = `halcyon.${k.slice(3)}`;
|
||||
if (localStorage.getItem(next) === null) localStorage.setItem(next, localStorage.getItem(k));
|
||||
localStorage.removeItem(k);
|
||||
}
|
||||
} catch { /* storage unavailable: nothing to carry over */ }
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
@@ -7,7 +22,7 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const KEY = 'wh.key';
|
||||
const KEY = 'halcyon.key';
|
||||
export const keyStore = {
|
||||
get() {
|
||||
try { return localStorage.getItem(KEY); } catch { return null; }
|
||||
@@ -64,8 +79,12 @@ export const api = {
|
||||
me: (key) => call('GET', '/me', { key }),
|
||||
ships: () => call('GET', '/ships'),
|
||||
assemble: (source) => call('POST', '/assemble', { body: source, type: 'text/plain', auth: false }),
|
||||
setProgram: (id, bytes) => call('PUT', `/ships/${id}/program`, { body: bytes, type: 'application/octet-stream' }),
|
||||
launch: (id) => call('POST', `/ships/${id}/launch`),
|
||||
// Launch a docked ship, flying the program in code slot `slot`.
|
||||
launch: (id, slot) => call('POST', `/ships/${id}/launch`, { json: { slot } }),
|
||||
slots: () => call('GET', '/slots'),
|
||||
slot: (n) => call('GET', `/slots/${n}`),
|
||||
saveSlot: (n, name, source) => call('PUT', `/slots/${n}`, { json: { name, source } }),
|
||||
deleteSlot: (n) => call('DELETE', `/slots/${n}`),
|
||||
setUplink: (id, bytes) => call('PUT', `/ships/${id}/uplink`, { body: bytes, type: 'application/octet-stream' }),
|
||||
async downlink(id) {
|
||||
const res = await call('GET', `/ships/${id}/downlink`, { raw: true });
|
||||
@@ -79,10 +98,6 @@ export const api = {
|
||||
if (!res.ok) throw new ApiError(res.status, `example ${name} not found`);
|
||||
return res.text();
|
||||
},
|
||||
async examples() {
|
||||
const res = await fetch('/examples/index.json');
|
||||
return res.ok ? res.json() : [];
|
||||
},
|
||||
};
|
||||
|
||||
export function b64ToBytes(b64) {
|
||||
|
||||
+12
-16
@@ -1,8 +1,8 @@
|
||||
// Halcyon Flight Control: application shell and routing.
|
||||
|
||||
import { api, keyStore, onUnauthorized } from './api.js';
|
||||
import { api, keyStore, migrateStorage, onUnauthorized } from './api.js';
|
||||
import { h, clear, toast } from './dom.js';
|
||||
import { startStarfield } from './starfield.js';
|
||||
import { startBackdrop } from './backdrop.js';
|
||||
import { statusOf } from './status.js';
|
||||
import { authView } from './view-auth.js';
|
||||
import { fleetView } from './view-fleet.js';
|
||||
@@ -18,9 +18,8 @@ const TABS = [
|
||||
['downlink', 'DOWNLINK'],
|
||||
];
|
||||
const VIEWS = { fleet: fleetView, code: codeView, uplink: uplinkView, downlink: downlinkView };
|
||||
const ORE_TAGS = { iron: 'FE', nickel: 'NI', ice: 'H2O', platinum: 'PT' };
|
||||
|
||||
const state = { info: null, me: null, market: null, ships: [], selected: null, tab: 'fleet' };
|
||||
const state = { info: null, me: null, ships: [], selected: null, tab: 'fleet' };
|
||||
let shell = null; // { top, nav, select, main }
|
||||
|
||||
const tabFromHash = () => {
|
||||
@@ -36,7 +35,7 @@ const ctx = {
|
||||
},
|
||||
selectShip(id, tab) {
|
||||
state.selected = id;
|
||||
try { localStorage.setItem('wh.ship', String(id)); } catch { /* ignore */ }
|
||||
try { localStorage.setItem('halcyon.ship', String(id)); } catch { /* ignore */ }
|
||||
renderNav();
|
||||
if (tab) ctx.go(tab); else renderMain();
|
||||
},
|
||||
@@ -49,9 +48,9 @@ const ctx = {
|
||||
};
|
||||
|
||||
async function loadAccount() {
|
||||
const [me, ships, market, info] = await Promise.all([api.me(), api.ships(), api.market().catch(() => null), api.info().catch(() => state.info)]);
|
||||
Object.assign(state, { me, ships, market, info });
|
||||
const remembered = Number(safeGet('wh.ship'));
|
||||
const [me, ships, info] = await Promise.all([api.me(), api.ships(), api.info().catch(() => state.info)]);
|
||||
Object.assign(state, { me, ships, info });
|
||||
const remembered = Number(safeGet('halcyon.ship'));
|
||||
if (!ships.some((s) => s.id === state.selected)) {
|
||||
state.selected = ships.some((s) => s.id === remembered) ? remembered : (ships[0]?.id ?? null);
|
||||
}
|
||||
@@ -98,22 +97,18 @@ function buildShell() {
|
||||
shell.top,
|
||||
shell.nav,
|
||||
shell.main,
|
||||
h('footer', { class: 'foot' }, 'HALCYON INSTRUMENT & CONTROL // WORMHOLE LINK 1 KB/DAY // THE BELT IS SIMULATED ONCE A DAY'),
|
||||
h('footer', { class: 'foot' }, 'HALCYON INSTRUMENT & CONTROL'),
|
||||
);
|
||||
}
|
||||
|
||||
function renderTop() {
|
||||
if (!shell) return;
|
||||
const { me, info, market } = state;
|
||||
const prices = market && info?.ores
|
||||
? info.ores.map((o, i) => h('span', { class: 'chip ore', title: `${o} price per kg` }, h('b', null, ORE_TAGS[o] ?? o.toUpperCase()), ` ${market[i]}`))
|
||||
: [h('span', { class: 'chip dim', title: 'Prices appear after the first daily run' }, 'MARKET: NO DATA YET')];
|
||||
const { me, info } = state;
|
||||
clear(shell.top).append(
|
||||
h('div', { class: 'top-brand' }, h('span', { class: 'logo sm' }, 'HALCYON'), h('span', { class: 'dim' }, 'FLIGHT CONTROL')),
|
||||
h('div', { class: 'top-stats' },
|
||||
h('span', { class: 'chip' }, 'DAY ', h('b', null, String(info?.day ?? me?.day ?? 0))),
|
||||
h('span', { class: 'chip' }, 'CREDITS ', h('b', null, (me?.credits ?? 0).toLocaleString('en-US'))),
|
||||
...prices,
|
||||
),
|
||||
h('div', { class: 'top-user' },
|
||||
h('span', { class: 'callsign' }, me?.name?.toUpperCase() ?? ''),
|
||||
@@ -134,7 +129,7 @@ function renderNav() {
|
||||
clear(shell.nav).append(
|
||||
h('div', { class: 'tabs' },
|
||||
TABS.map(([id, label]) => h('a', { class: `tab ${state.tab === id ? 'on' : ''}`, href: `#/${id}`, 'aria-current': state.tab === id ? 'page' : null }, label)),
|
||||
h('a', { class: 'tab', href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'MANUAL'),
|
||||
h('a', { class: 'tab', href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'MANUAL'),
|
||||
),
|
||||
h('label', { class: 'shipsel' }, 'ACTIVE SHIP', select),
|
||||
);
|
||||
@@ -160,7 +155,8 @@ document.addEventListener('visibilitychange', () => {
|
||||
|
||||
// ---- boot -------------------------------------------------------------------
|
||||
|
||||
startStarfield(document.getElementById('stars'));
|
||||
migrateStorage(); // before anything reads the key
|
||||
startBackdrop(document.getElementById('backdrop'));
|
||||
onUnauthorized(() => logout('SESSION REJECTED: ACCESS KEY NOT RECOGNISED'));
|
||||
api.info().then((i) => { state.info = i; }).catch(() => {});
|
||||
if (keyStore.get()) enter(); else showAuth();
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// The animated backdrop, all on one canvas: drifting, twinkling stars with the
|
||||
// odd shooting star, the striped sun, and a perspective grid floor sliding
|
||||
// towards you. One canvas is one composited layer: no CSS 3D transforms, masks or
|
||||
// animations, which is what made the old CSS floor flicker in mobile Safari.
|
||||
//
|
||||
// Built to survive mobile browsers: star positions are stored as fractions of
|
||||
// the viewport so a resize (iOS fires them constantly as the toolbar collapses)
|
||||
// never re-scatters them; a frame that throws cannot kill the loop; and the loop
|
||||
// is restarted if it is ever found dead (dropped frame, back/forward cache,
|
||||
// throttled tab), so the animation cannot silently stop.
|
||||
|
||||
const STAR_COUNT = 320;
|
||||
const TINTS = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0'];
|
||||
|
||||
export function startBackdrop(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
// Positions are fractions of the viewport (nx, ny in [0, 1)).
|
||||
const stars = Array.from({ length: STAR_COUNT }, () => ({
|
||||
nx: Math.random(),
|
||||
ny: Math.random(),
|
||||
z: 0.25 + Math.random() * 0.75,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.6 + Math.random() * 1.8,
|
||||
tint: TINTS[Math.floor(Math.random() * TINTS.length)],
|
||||
}));
|
||||
let shooters = [];
|
||||
let nextShot = 4000;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
let dpr = 1;
|
||||
let horizonY = 0; // where the floor starts
|
||||
let floorH = 0;
|
||||
let staticLayer = null; // sun + floor base, painted once per size
|
||||
let staticTop = 0; // the layer is empty above this row, so only the part below it is drawn
|
||||
let fade = null; // gradient that dissolves the grid into the horizon
|
||||
let shown = 0; // how many stars fit the current viewport
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
let lastFrame = performance.now();
|
||||
let resizeTimer = 0;
|
||||
|
||||
function resize() {
|
||||
// The canvas is a fixed box of 100vh anchored to the bottom (see the CSS), so
|
||||
// it does not change size when a phone's toolbar slides in and out.
|
||||
const nw = canvas.clientWidth;
|
||||
const nh = canvas.clientHeight;
|
||||
if (!nw || !nh) return;
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
if (nw !== w || nh !== h || canvas.width !== Math.round(nw * dpr)) {
|
||||
w = nw;
|
||||
h = nh;
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
floorH = Math.round(h * 0.17);
|
||||
horizonY = h - floorH;
|
||||
staticLayer = buildStatic();
|
||||
fade = ctx.createLinearGradient(0, horizonY, 0, horizonY + floorH * 0.55);
|
||||
fade.addColorStop(0, 'rgba(34,9,63,1)');
|
||||
fade.addColorStop(1, 'rgba(34,9,63,0)');
|
||||
}
|
||||
shown = Math.min(STAR_COUNT, Math.round((w * h) / 5500));
|
||||
draw(performance.now());
|
||||
}
|
||||
|
||||
/** The parts that never move, painted once: the striped sun and the floor's base. */
|
||||
function buildStatic() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = canvas.width;
|
||||
c.height = canvas.height;
|
||||
const g = c.getContext('2d');
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const S = Math.max(180, Math.min(400, 0.34 * Math.min(w, h)));
|
||||
const R = S / 2;
|
||||
const cx = w / 2;
|
||||
const cy = horizonY - 0.16 * S; // its lower third is hidden behind the floor
|
||||
staticTop = Math.max(0, Math.floor(cy - R - 60)); // top of the sun's halo
|
||||
|
||||
const halo = g.createRadialGradient(cx, cy, R, cx, cy, R + 60);
|
||||
halo.addColorStop(0, 'rgba(255,58,216,0.30)');
|
||||
halo.addColorStop(1, 'rgba(255,58,216,0)');
|
||||
g.fillStyle = halo;
|
||||
g.beginPath();
|
||||
g.arc(cx, cy, R + 60, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
|
||||
const t = document.createElement('canvas'); // the disc, with its stripes cut out
|
||||
t.width = t.height = Math.ceil(S * dpr);
|
||||
const tg = t.getContext('2d');
|
||||
tg.scale(dpr, dpr);
|
||||
const sun = tg.createLinearGradient(0, 0, 0, S);
|
||||
sun.addColorStop(0, '#fff27a');
|
||||
sun.addColorStop(0.32, '#ffb62e');
|
||||
sun.addColorStop(0.72, '#ff3ad8');
|
||||
sun.addColorStop(1, '#8a2cff');
|
||||
tg.fillStyle = sun;
|
||||
tg.beginPath();
|
||||
tg.arc(R, R, R, 0, Math.PI * 2);
|
||||
tg.fill();
|
||||
tg.globalCompositeOperation = 'destination-out';
|
||||
for (const [from, to] of [[0.46, 0.49], [0.56, 0.6], [0.67, 0.72], [0.79, 0.85], [0.91, 0.98]]) {
|
||||
tg.fillRect(0, from * S, S, (to - from) * S);
|
||||
}
|
||||
g.drawImage(t, cx - R, cy - R, S, S);
|
||||
|
||||
const base = g.createLinearGradient(0, horizonY, 0, h);
|
||||
base.addColorStop(0, '#22093f');
|
||||
base.addColorStop(1, '#0c0420');
|
||||
g.fillStyle = base;
|
||||
g.fillRect(0, horizonY, w, floorH);
|
||||
const glow = g.createLinearGradient(0, horizonY - 28, 0, horizonY);
|
||||
glow.addColorStop(0, 'rgba(40,245,255,0)');
|
||||
glow.addColorStop(1, 'rgba(40,245,255,0.45)');
|
||||
g.fillStyle = glow;
|
||||
g.fillRect(0, horizonY - 28, w, 28);
|
||||
g.fillStyle = '#28f5ff';
|
||||
g.fillRect(0, horizonY - 1, w, 2);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** The grid: lines at even steps in depth, projected onto the floor. */
|
||||
function drawGrid(now) {
|
||||
const Z_FAR = 26;
|
||||
const phase = reduce.matches ? 0 : ((now / 1000) * 0.42) % 1; // depth steps per second
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, horizonY, w, floorH);
|
||||
ctx.clip();
|
||||
ctx.strokeStyle = '#ff3ad8';
|
||||
|
||||
// lines across, sliding towards the viewer
|
||||
for (let j = 0; j < Z_FAR; j++) {
|
||||
const z = 1 + j - phase;
|
||||
const y = horizonY + floorH / z;
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.lineWidth = 0.7 + 1.5 / Math.max(z, 1);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// lines running into the distance, meeting at the vanishing point
|
||||
const spacing = floorH * 0.55;
|
||||
const n = Math.ceil((w / 2 / spacing) * 6);
|
||||
const cx = w / 2;
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (let i = -n; i <= n; i++) {
|
||||
ctx.moveTo(cx + (i * spacing) / Z_FAR, horizonY + floorH / Z_FAR);
|
||||
ctx.lineTo(cx + (i * spacing) / 0.4, horizonY + floorH / 0.4);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// dissolve everything into the horizon so distant lines cannot shimmer
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = fade;
|
||||
ctx.fillRect(0, horizonY, w, floorH * 0.55);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function draw(now) {
|
||||
if (!staticLayer) return;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const s = stars[i];
|
||||
const tw = 0.5 + 0.5 * Math.sin((now / 1000) * s.speed + s.phase);
|
||||
ctx.globalAlpha = reduce.matches ? 0.5 * s.z : (0.25 + 0.75 * tw) * s.z;
|
||||
ctx.fillStyle = s.tint;
|
||||
const r = 0.4 + s.z * 1.3;
|
||||
ctx.fillRect(s.nx * w, s.ny * h, r, r);
|
||||
}
|
||||
for (const s of shooters) {
|
||||
const g = ctx.createLinearGradient(s.x, s.y, s.x - s.vx * 9, s.y - s.vy * 9);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.95)');
|
||||
g.addColorStop(1, 'rgba(255,58,216,0)');
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, s.life));
|
||||
ctx.strokeStyle = g;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.x, s.y);
|
||||
ctx.lineTo(s.x - s.vx * 9, s.y - s.vy * 9);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
// sun and floor over the stars; only the part of the layer that has anything in it
|
||||
ctx.drawImage(staticLayer, 0, staticTop * dpr, canvas.width, canvas.height - staticTop * dpr, 0, staticTop, w, h - staticTop);
|
||||
drawGrid(now);
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const s = stars[i];
|
||||
s.nx -= (s.z * 0.012 * dt) / w;
|
||||
if (s.nx < 0) {
|
||||
s.nx += 1;
|
||||
s.ny = Math.random();
|
||||
}
|
||||
}
|
||||
nextShot -= dt;
|
||||
if (nextShot <= 0) {
|
||||
nextShot = 7000 + Math.random() * 9000;
|
||||
shooters.push({ x: Math.random() * w * 0.8 + w * 0.2, y: Math.random() * h * 0.4, vx: -(6 + Math.random() * 4), vy: 2 + Math.random() * 2, life: 1.4 });
|
||||
}
|
||||
for (const s of shooters) {
|
||||
s.x += (s.vx * dt) / 16;
|
||||
s.y += (s.vy * dt) / 16;
|
||||
s.life -= dt / 900;
|
||||
}
|
||||
shooters = shooters.filter((s) => s.life > 0);
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
raf = 0;
|
||||
try {
|
||||
const dt = Math.max(0, Math.min(64, now - last));
|
||||
last = now;
|
||||
update(dt);
|
||||
draw(now);
|
||||
} catch (e) {
|
||||
console.error('starfield frame failed; continuing', e);
|
||||
}
|
||||
lastFrame = performance.now();
|
||||
schedule();
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (raf || reduce.matches || document.visibilityState !== 'visible') return;
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
/** (Re)start the loop from a clean state. Safe to call at any time. */
|
||||
function restart() {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
last = lastFrame = performance.now();
|
||||
if (reduce.matches) draw(last);
|
||||
else schedule();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(resize, 120);
|
||||
});
|
||||
window.addEventListener('orientationchange', () => setTimeout(resize, 250));
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') restart(); });
|
||||
window.addEventListener('pageshow', restart); // back/forward cache restore
|
||||
window.addEventListener('focus', restart);
|
||||
reduce.addEventListener('change', restart);
|
||||
|
||||
// Watchdog: a visible page whose loop has gone quiet gets restarted.
|
||||
setInterval(() => {
|
||||
if (!reduce.matches && document.visibilityState === 'visible' && performance.now() - lastFrame > 1500) restart();
|
||||
}, 1000);
|
||||
|
||||
resize();
|
||||
restart();
|
||||
}
|
||||
@@ -52,6 +52,33 @@ export function confirmDialog({ title, body, confirmText = 'CONFIRM', cancelText
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A modal dialog around caller-built content (form fields and so on). Returns
|
||||
* { result, confirm, close }: `result` resolves true on the confirm button and
|
||||
* false on cancel or Escape; the caller can enable/disable `confirm` as it
|
||||
* validates, and reads its own fields after `result` resolves.
|
||||
*/
|
||||
export function customDialog({ title, content, confirmText = 'OK', cancelText = 'CANCEL', danger = false }) {
|
||||
const dlg = document.getElementById('dialog');
|
||||
let resolve;
|
||||
const result = new Promise((r) => { resolve = r; });
|
||||
const finish = (v) => {
|
||||
if (dlg.open) dlg.close();
|
||||
resolve(v);
|
||||
};
|
||||
const confirm = h('button', { class: `btn ${danger ? 'danger' : 'primary'}`, type: 'button', onclick: () => finish(true) }, confirmText);
|
||||
clear(dlg).append(
|
||||
h('div', { class: `panel dialog ${danger ? 'danger' : ''}` },
|
||||
h('div', { class: 'panel-title' }, title),
|
||||
h('div', { class: 'panel-body stack' }, ...content),
|
||||
h('div', { class: 'actions' }, h('button', { class: 'btn', type: 'button', onclick: () => finish(false) }, cancelText), confirm),
|
||||
),
|
||||
);
|
||||
dlg.oncancel = () => resolve(false);
|
||||
dlg.showModal();
|
||||
return { result, confirm, close: finish };
|
||||
}
|
||||
|
||||
export function download(filename, bytes, type = 'application/octet-stream') {
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type }));
|
||||
const a = h('a', { href: url, download: filename });
|
||||
|
||||
+55
-12
@@ -31,24 +31,38 @@ export function describe(bytes, i) {
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
// Row layout, in character cells: offset, gap, hex, gap, ASCII.
|
||||
const BYTES_PER_ROW = [16, 8, 4]; // widest first
|
||||
const GAP = 1.2;
|
||||
const rowChars = (n) => 4 + GAP * 2 + n * 3 + (n === 16 ? 1 : 0) + n;
|
||||
|
||||
/**
|
||||
* Render a hex dump. Returns { el, inspector } where inspector is the element
|
||||
* that shows the decoded value of the hovered/selected byte.
|
||||
* Render a hex dump that always fits its container: it picks 16, 8 or 4 bytes
|
||||
* per row and sizes the font so a whole row fits without sideways scrolling, and
|
||||
* re-fits whenever the container changes size (rotation, window resize).
|
||||
* Returns { el, inspector }; the inspector shows the decoded hovered/selected byte.
|
||||
*
|
||||
* Options: maxBytes (show only the first N bytes), maxFont / minFont (px).
|
||||
*/
|
||||
export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
|
||||
export function hexView(bytes, { maxBytes = Infinity, maxFont = 15, minFont = 9.5 } = {}) {
|
||||
const inspector = h('div', { class: 'hx-inspector', 'aria-live': 'polite' }, 'HOVER OR CLICK A BYTE TO DECODE IT');
|
||||
const body = h('div', { class: 'hx-body', tabindex: '0', role: 'group', 'aria-label': 'Hex dump' });
|
||||
const total = Math.min(bytes.length, maxBytes);
|
||||
let pinned = null;
|
||||
const cells = new Map();
|
||||
let cells = new Map();
|
||||
let perRow = 0;
|
||||
let ratio = 0; // width of one character as a fraction of the font size
|
||||
|
||||
const rowCount = Math.min(Math.ceil(bytes.length / 16), maxRows);
|
||||
for (let r = 0; r < rowCount; r++) {
|
||||
const start = r * 16;
|
||||
function build(n) {
|
||||
perRow = n;
|
||||
cells = new Map();
|
||||
const rows = [];
|
||||
for (let start = 0; start < total; start += n) {
|
||||
const hexCells = [];
|
||||
const ascCells = [];
|
||||
for (let k = 0; k < 16; k++) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
const i = start + k;
|
||||
if (i >= bytes.length) {
|
||||
if (i >= total) {
|
||||
hexCells.push(h('span', { class: 'hx-b pad' }, ' '));
|
||||
continue;
|
||||
}
|
||||
@@ -59,9 +73,9 @@ export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
|
||||
cells.set(i, [x, a]);
|
||||
hexCells.push(x);
|
||||
ascCells.push(a);
|
||||
if (k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' '));
|
||||
if (n === 16 && k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' '));
|
||||
}
|
||||
body.append(
|
||||
rows.push(
|
||||
h('div', { class: 'hx-row' },
|
||||
h('span', { class: 'hx-off' }, pad(start.toString(16).toUpperCase(), 4)),
|
||||
h('span', { class: 'hx-hex' }, hexCells),
|
||||
@@ -69,12 +83,37 @@ export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
|
||||
),
|
||||
);
|
||||
}
|
||||
body.replaceChildren(...rows);
|
||||
if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.add('sel'));
|
||||
}
|
||||
|
||||
function measure() {
|
||||
if (ratio) return ratio;
|
||||
const probe = h('span', { class: 'hx-probe' }, 'M'.repeat(20));
|
||||
body.append(probe);
|
||||
ratio = probe.getBoundingClientRect().width / 20 / 100;
|
||||
probe.remove();
|
||||
return ratio || 0.6;
|
||||
}
|
||||
|
||||
function layout() {
|
||||
const cs = getComputedStyle(body);
|
||||
const avail = body.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
if (!(avail > 0)) return; // not laid out yet
|
||||
const r = measure();
|
||||
const fontFor = (n) => avail / (rowChars(n) * r);
|
||||
// The widest layout that still gives a readable font; keep the current one a
|
||||
// little longer than that, so a scrollbar appearing cannot make it flap.
|
||||
let n = BYTES_PER_ROW.find((c) => fontFor(c) >= minFont - (c === perRow ? 0.6 : 0)) ?? 4;
|
||||
body.style.fontSize = `${Math.min(maxFont, fontFor(n)).toFixed(2)}px`;
|
||||
if (n !== perRow) build(n);
|
||||
}
|
||||
|
||||
const select = (i, pin) => {
|
||||
if (pin) {
|
||||
if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.remove('sel'));
|
||||
pinned = pinned === i ? null : i;
|
||||
if (pinned !== null) cells.get(pinned).forEach((c) => c.classList.add('sel'));
|
||||
if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.add('sel'));
|
||||
}
|
||||
const shown = pinned ?? i;
|
||||
inspector.textContent = shown === null ? 'HOVER OR CLICK A BYTE TO DECODE IT' : describe(bytes, shown);
|
||||
@@ -87,6 +126,10 @@ export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
|
||||
body.addEventListener('mouseleave', () => { if (pinned === null) select(null, false); });
|
||||
body.addEventListener('click', (e) => { const i = target(e); if (i !== null) select(i, true); });
|
||||
|
||||
build(16);
|
||||
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(layout).observe(body); // fires once attached, before first paint
|
||||
else window.addEventListener('resize', layout);
|
||||
|
||||
return { el: h('div', { class: 'hex' }, body, inspector), inspector };
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,7 @@
|
||||
<script type="module" src="app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="stars" aria-hidden="true"></canvas>
|
||||
<div class="horizon" aria-hidden="true">
|
||||
<div class="sun"></div>
|
||||
<div class="floor"></div>
|
||||
</div>
|
||||
<canvas id="backdrop" aria-hidden="true"></canvas>
|
||||
<div class="crt" aria-hidden="true"></div>
|
||||
|
||||
<div id="app">
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
// Slowly drifting, twinkling starfield with the odd shooting star.
|
||||
|
||||
export function startStarfield(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const tints = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0'];
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
let stars = [];
|
||||
let shooters = [];
|
||||
let nextShot = 4000;
|
||||
let last = performance.now();
|
||||
let raf = 0;
|
||||
|
||||
function resize() {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
w = window.innerWidth;
|
||||
h = window.innerHeight;
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const n = Math.min(320, Math.round((w * h) / 5500));
|
||||
stars = Array.from({ length: n }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
z: 0.25 + Math.random() * 0.75,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.6 + Math.random() * 1.8,
|
||||
tint: tints[Math.floor(Math.random() * tints.length)],
|
||||
}));
|
||||
draw(performance.now());
|
||||
}
|
||||
|
||||
function draw(now) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
for (const s of stars) {
|
||||
const tw = 0.5 + 0.5 * Math.sin(now / 1000 * s.speed + s.phase);
|
||||
ctx.globalAlpha = reduce.matches ? 0.5 * s.z : (0.25 + 0.75 * tw) * s.z;
|
||||
ctx.fillStyle = s.tint;
|
||||
const r = 0.4 + s.z * 1.3;
|
||||
ctx.fillRect(s.x, s.y, r, r);
|
||||
}
|
||||
for (const s of shooters) {
|
||||
const g = ctx.createLinearGradient(s.x, s.y, s.x - s.vx * 9, s.y - s.vy * 9);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.95)');
|
||||
g.addColorStop(1, 'rgba(255,58,216,0)');
|
||||
ctx.globalAlpha = Math.min(1, s.life);
|
||||
ctx.strokeStyle = g;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.x, s.y);
|
||||
ctx.lineTo(s.x - s.vx * 9, s.y - s.vy * 9);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min(64, now - last);
|
||||
last = now;
|
||||
for (const s of stars) {
|
||||
s.x -= s.z * 0.012 * dt;
|
||||
if (s.x < -2) { s.x = w + 2; s.y = Math.random() * h; }
|
||||
}
|
||||
nextShot -= dt;
|
||||
if (nextShot <= 0) {
|
||||
nextShot = 7000 + Math.random() * 9000;
|
||||
shooters.push({ x: Math.random() * w * 0.8 + w * 0.2, y: Math.random() * h * 0.4, vx: -(6 + Math.random() * 4), vy: 2 + Math.random() * 2, life: 1.4 });
|
||||
}
|
||||
for (const s of shooters) {
|
||||
s.x += s.vx * dt / 16;
|
||||
s.y += s.vy * dt / 16;
|
||||
s.life -= dt / 900;
|
||||
}
|
||||
shooters = shooters.filter((s) => s.life > 0);
|
||||
draw(now);
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function sync() {
|
||||
cancelAnimationFrame(raf);
|
||||
if (!reduce.matches) raf = requestAnimationFrame((t) => { last = t; frame(t); });
|
||||
else draw(performance.now());
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
reduce.addEventListener('change', sync);
|
||||
resize();
|
||||
sync();
|
||||
}
|
||||
+63
-49
@@ -26,7 +26,7 @@ body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%) fixed;
|
||||
background: #07030f;
|
||||
font-family: var(--mono);
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
@@ -35,50 +35,21 @@ body {
|
||||
|
||||
/* ---- backdrop ------------------------------------------------------------ */
|
||||
|
||||
#stars { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 0; }
|
||||
|
||||
.horizon {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; height: 46vh;
|
||||
z-index: 1; pointer-events: none; overflow: hidden;
|
||||
--sun: clamp(180px, 34vmin, 400px);
|
||||
--floor: 17vh;
|
||||
/* The whole backdrop is one canvas. It is a fixed box of 100vh anchored to the
|
||||
bottom of the screen, so it never resizes when a phone's toolbar comes and goes
|
||||
and the floor always sits at the visible bottom. */
|
||||
#backdrop {
|
||||
position: fixed; left: 0; bottom: 0; width: 100%; height: 100vh; height: 100lvh; z-index: 0;
|
||||
background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%);
|
||||
}
|
||||
|
||||
.sun {
|
||||
position: absolute; left: 50%; margin-left: calc(var(--sun) / -2);
|
||||
bottom: calc(var(--floor) - var(--sun) * 0.34);
|
||||
width: var(--sun); height: var(--sun); border-radius: 50%;
|
||||
background: linear-gradient(180deg, #fff27a 0%, #ffb62e 32%, #ff3ad8 72%, #8a2cff 100%);
|
||||
box-shadow: 0 0 70px 12px rgba(255, 58, 216, 0.5);
|
||||
-webkit-mask-image: linear-gradient(#000 0 46%, transparent 46% 49%, #000 49% 56%, transparent 56% 60%, #000 60% 67%, transparent 67% 72%, #000 72% 79%, transparent 79% 85%, #000 85% 91%, transparent 91% 98%, #000 98%);
|
||||
mask-image: linear-gradient(#000 0 46%, transparent 46% 49%, #000 49% 56%, transparent 56% 60%, #000 60% 67%, transparent 67% 72%, #000 72% 79%, transparent 79% 85%, #000 85% 91%, transparent 91% 98%, #000 98%);
|
||||
}
|
||||
|
||||
.floor {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; height: var(--floor);
|
||||
overflow: hidden; background: linear-gradient(#22093f, #0c0420);
|
||||
border-top: 2px solid var(--cyan);
|
||||
box-shadow: 0 -2px 26px rgba(40, 245, 255, 0.7);
|
||||
}
|
||||
.floor::before {
|
||||
content: ""; position: absolute; left: -60%; right: -60%; top: 0; height: 400%;
|
||||
transform-origin: 50% 0; transform: perspective(240px) rotateX(64deg);
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 58, 216, 0.8) 2px, transparent 2px),
|
||||
linear-gradient(90deg, rgba(255, 58, 216, 0.8) 2px, transparent 2px);
|
||||
background-size: 64px 64px;
|
||||
animation: grid-move 2.6s linear infinite;
|
||||
}
|
||||
@keyframes grid-move { to { background-position: 0 64px; } }
|
||||
|
||||
/* Static scanlines and vignette. (They used to flicker; that was the flicker.) */
|
||||
.crt {
|
||||
position: fixed; inset: 0; z-index: 100; pointer-events: none;
|
||||
background:
|
||||
repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0) 0 2px, rgba(0, 0, 0, 0.17) 3px, rgba(0, 0, 0, 0) 4px),
|
||||
radial-gradient(ellipse at center, transparent 58%, rgba(0, 0, 0, 0.5) 100%);
|
||||
animation: flicker 7s infinite;
|
||||
}
|
||||
@keyframes flicker { 0%, 100% { opacity: 1; } 47% { opacity: 1; } 48% { opacity: 0.86; } 50% { opacity: 1; } 83% { opacity: 0.93; } 84% { opacity: 1; } }
|
||||
|
||||
#app, dialog, #toasts { position: relative; z-index: 10; }
|
||||
|
||||
@@ -120,20 +91,23 @@ a:hover { color: var(--mag); }
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1.4rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: rgba(7, 3, 15, 0.88); backdrop-filter: blur(6px);
|
||||
background: rgba(7, 3, 15, 0.96);
|
||||
border-bottom: 1px solid var(--line); box-shadow: 0 2px 24px rgba(40, 245, 255, 0.12);
|
||||
}
|
||||
.top-brand { display: flex; align-items: baseline; gap: 0.9rem; letter-spacing: 0.25em; font-size: 0.85rem; }
|
||||
.top-stats { display: flex; flex-wrap: wrap; gap: 0.5rem; flex: 1; }
|
||||
.top-user { display: flex; align-items: center; gap: 0.6rem; margin-left: auto; }
|
||||
.callsign { color: var(--lime); letter-spacing: 0.15em; text-shadow: 0 0 8px rgba(125, 255, 138, 0.6); }
|
||||
/* Callsigns can be 40 characters: truncate them, never the buttons beside them. */
|
||||
.top-user { min-width: 0; max-width: 100%; }
|
||||
.callsign { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.top-user .btn { flex: none; }
|
||||
|
||||
.chip { border: 1px solid var(--faint); padding: 0.05rem 0.65rem; font-size: 0.9rem; letter-spacing: 0.08em; background: rgba(0, 0, 0, 0.3); }
|
||||
.chip b { color: var(--amber); font-weight: 700; }
|
||||
.chip.ore b { color: var(--cyan); }
|
||||
|
||||
.navbar { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.6rem; max-width: 1100px; margin: 1rem auto 0; padding: 0 1rem; }
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.tabs { display: flex; flex-wrap: nowrap; gap: 0.3rem; min-width: 0; }
|
||||
.tab {
|
||||
font-family: var(--display); font-size: 0.85rem; letter-spacing: 0.22em; text-transform: uppercase; text-decoration: none;
|
||||
padding: 0.6rem 1.2rem; color: var(--dim); background: rgba(13, 6, 34, 0.7);
|
||||
@@ -233,9 +207,28 @@ textarea { resize: vertical; }
|
||||
|
||||
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0.6rem 0; }
|
||||
.toolbar.pad { margin: 0; }
|
||||
.sealrow { justify-content: space-between; margin-top: 0.8rem; }
|
||||
.sealrow .grow { flex: 1 1 260px; }
|
||||
.panel-title .dim { white-space: nowrap; }
|
||||
|
||||
/* Button bars in the code editor: one control height and style throughout. */
|
||||
.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 1rem; margin: 0.8rem 0; }
|
||||
.bar-label { flex: none; width: 4.2rem; font-size: 0.72rem; letter-spacing: 0.24em; color: var(--dim); }
|
||||
.bar-buttons { display: flex; flex: 1; flex-wrap: wrap; gap: 0.5rem; min-width: 0; }
|
||||
.bar .btn, .bar select {
|
||||
height: 2.5rem; margin: 0; padding: 0 1.1rem; font-size: 0.82rem; line-height: 1; white-space: nowrap;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.bar .push { margin-left: auto; }
|
||||
.bar select { min-width: 0; max-width: 100%; text-overflow: ellipsis; }
|
||||
.stack-lg > * { min-width: 0; }
|
||||
/* The example chooser looks like the buttons beside it. */
|
||||
.bar select {
|
||||
-webkit-appearance: none; appearance: none; width: auto; cursor: pointer;
|
||||
font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--cyan);
|
||||
background: rgba(40, 245, 255, 0.06) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2328f5ff' stroke-width='1.6'/%3E%3C/svg%3E") no-repeat right 0.9rem center / 0.65rem;
|
||||
border: 1px solid var(--cyan); box-shadow: 0 0 10px rgba(40, 245, 255, 0.25); padding-right: 2.4rem;
|
||||
}
|
||||
.bar select:hover { background-color: rgba(40, 245, 255, 0.16); }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.panel-title .dim { min-width: 0; text-align: right; overflow-wrap: anywhere; }
|
||||
|
||||
.badge { display: inline-block; padding: 0.05rem 0.7rem; font-size: 0.8rem; letter-spacing: 0.15em; border: 1px solid currentColor; text-shadow: 0 0 8px currentColor; white-space: nowrap; }
|
||||
.badge.amber { color: var(--amber); }
|
||||
@@ -315,15 +308,13 @@ textarea { resize: vertical; }
|
||||
.drop.disabled { opacity: 0.4; cursor: not-allowed; border-color: var(--faint); }
|
||||
.uplink-grid { grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); }
|
||||
.fleet-grid { grid-template-columns: minmax(0, 1fr) 330px; }
|
||||
.uplink-preview .hx-body { font-size: 13px; }
|
||||
.uplink-preview .hx-row { gap: 0.9rem; }
|
||||
.panel-body > .hint, .panel-body > .hx-inspector + .hint { margin-top: 0.7rem; }
|
||||
.uplink-preview:empty::before { content: "NOTHING TO SEND YET."; color: var(--dim); letter-spacing: 0.15em; }
|
||||
|
||||
/* ---- hex viewer ---------------------------------------------------------- */
|
||||
|
||||
.hx-body { background: #05020c; border: 1px solid var(--faint); padding: 0.7rem 0.9rem; overflow-x: auto; font-size: 15px; line-height: 1.55; }
|
||||
.hx-row { display: flex; gap: 1.4rem; white-space: pre; width: max-content; }
|
||||
.hx-row { display: flex; gap: 1.2ch; white-space: pre; width: max-content; }
|
||||
.hx-off { color: var(--amber); }
|
||||
.hx-b { display: inline-block; width: 2ch; text-align: center; cursor: default; }
|
||||
.hx-hex .hx-b { margin-right: 1ch; }
|
||||
@@ -333,6 +324,7 @@ textarea { resize: vertical; }
|
||||
.hx-b:hover { background: rgba(40, 245, 255, 0.4); color: #fff; }
|
||||
.hx-b.sel { background: var(--mag); color: #fff; }
|
||||
.hx-gap { display: inline-block; width: 1ch; }
|
||||
.hx-probe { position: absolute; visibility: hidden; font-size: 100px; white-space: pre; }
|
||||
.hx-inspector { margin-top: 0.7rem; padding: 0.5rem 0.9rem; min-height: 2.6rem; border: 1px solid var(--line); color: var(--cyan); font-size: 0.95rem; letter-spacing: 0.06em; overflow-wrap: anywhere; }
|
||||
|
||||
.nosignal { text-align: center; padding: 2.5rem 1rem; }
|
||||
@@ -360,6 +352,12 @@ dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px);
|
||||
.dialog { width: min(480px, 92vw); }
|
||||
.dialog.danger { border-color: var(--red); }
|
||||
.dialog .panel-body p + p { margin-top: 0.7rem; }
|
||||
.dialog select, .dialog input[type="text"] { width: 100%; }
|
||||
.dialog select { color: var(--cyan); border-color: var(--line); }
|
||||
.launch-status { min-height: 1.5em; letter-spacing: 0.04em; }
|
||||
.launch-status.ok { color: var(--lime); }
|
||||
.launch-status.bad { color: var(--red); }
|
||||
.slot-status { margin: 0.2rem 0 0.7rem; }
|
||||
.dialog .actions { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0 1rem 1rem; }
|
||||
|
||||
/* ---- small screens ------------------------------------------------------- */
|
||||
@@ -372,8 +370,15 @@ dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px);
|
||||
body { font-size: 16px; }
|
||||
.topbar { position: static; }
|
||||
.toolbar .btn { padding: 0.4rem 0.8rem; font-size: 0.9rem; letter-spacing: 0.08em; }
|
||||
.sealrow .btn { width: 100%; }
|
||||
.panel-title { letter-spacing: 0.16em; }
|
||||
/* phones: a tidy two-column grid; the chooser and the primary action span the row */
|
||||
.bar { flex-direction: column; align-items: stretch; gap: 0.3rem; }
|
||||
.bar-label { width: auto; }
|
||||
.bar-buttons { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.bar select { width: 100%; }
|
||||
.bar .span2 { grid-column: 1 / -1; }
|
||||
.bar .push { margin-left: 0; }
|
||||
.bar .btn, .bar select { padding: 0 0.6rem; }
|
||||
.panel-title { letter-spacing: 0.16em; flex-wrap: wrap; row-gap: 0.15rem; }
|
||||
.auth { padding-bottom: 10rem; }
|
||||
.top-user { margin-left: 0; }
|
||||
.manifest thead { display: none; }
|
||||
@@ -382,10 +387,19 @@ dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px);
|
||||
.manifest td::before { content: attr(data-label); color: var(--dim); font-size: 0.75rem; letter-spacing: 0.2em; }
|
||||
.manifest td.actions { justify-content: flex-end; }
|
||||
.manifest td.actions::before { content: none; }
|
||||
.tab { padding: 0.5rem 0.8rem; letter-spacing: 0.12em; }
|
||||
.navbar { flex-direction: column; align-items: stretch; gap: 0.6rem; }
|
||||
.tabs { width: 100%; gap: 0.2rem; }
|
||||
.tab { flex: 1 1 auto; min-width: 0; padding: 0.55rem 0.2rem; font-size: 0.68rem; letter-spacing: 0.05em; text-align: center; white-space: nowrap; }
|
||||
.shipsel { justify-content: space-between; }
|
||||
.hx-body { padding: 0.6rem 0.5rem; }
|
||||
.editor { height: 52vh; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.bar .btn, .bar select { font-size: 0.7rem; letter-spacing: 0.05em; padding: 0 0.4rem; }
|
||||
.tab { font-size: 0.62rem; letter-spacing: 0.02em; padding-left: 0.1rem; padding-right: 0.1rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
+173
-63
@@ -1,27 +1,37 @@
|
||||
// Code editor: write assembly, check it, download it, seal it into a ship.
|
||||
// Code editor with sixteen saved slots. Slots 1-3 are read-only examples; 4-16
|
||||
// are the player's own. Launching a ship (Fleet tab) picks which slot it flies.
|
||||
|
||||
import { api, b64ToBytes } from './api.js';
|
||||
import { h, clear, toast, confirmDialog, download } from './dom.js';
|
||||
import { h, clear, toast, confirmDialog, customDialog, download, pad } from './dom.js';
|
||||
import { createEditor } from './editor.js';
|
||||
|
||||
const draftKey = (ship) => `wh.draft.${ship?.id ?? 0}`;
|
||||
const load = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
||||
const save = (k, v) => { try { localStorage.setItem(k, v); } catch { /* ignore */ } };
|
||||
const DEFAULT_SLOT = 2; // the telemetry example, a working program to start from
|
||||
|
||||
const readJSON = (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } };
|
||||
const writeJSON = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* ignore */ } };
|
||||
|
||||
export function codeView(ctx) {
|
||||
const ship = ctx.ship;
|
||||
const limit = ctx.state.info?.program_bytes ?? 4096;
|
||||
const key = draftKey(ship);
|
||||
const canSeal = ship?.status === 'inventory';
|
||||
// The unsaved working copy survives reloads, per account and per browser.
|
||||
const draftKey = `halcyon.editor.${ctx.state.me?.name ?? ''}`;
|
||||
|
||||
const cursor = h('span', { class: 'dim' }, 'LN 1 COL 1');
|
||||
let slots = []; // [{ slot, name, builtin, empty }] from the server
|
||||
let current = { slot: DEFAULT_SLOT, name: '', builtin: true, saved: '' }; // what the editor is bound to
|
||||
let ready = false;
|
||||
let timer = 0;
|
||||
|
||||
const info = (n) => slots.find((s) => s.slot === n) ?? { slot: n, name: '', builtin: false, empty: true };
|
||||
const slotLabel = (s) => `${pad(s.slot, 2)} ${s.builtin ? `${s.name} (EXAMPLE)` : s.empty ? '(EMPTY)' : s.name}`;
|
||||
|
||||
const cursor = h('span', { class: 'dim nowrap' }, 'LN 1 COL 1');
|
||||
const editor = createEditor({
|
||||
value: load(key) ?? '',
|
||||
onInput: (v) => { clearTimeout(timer); timer = setTimeout(() => save(key, v), 300); },
|
||||
value: '',
|
||||
onInput: (v) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => writeJSON(draftKey, { slot: current.slot, text: v }), 150);
|
||||
renderStatus();
|
||||
},
|
||||
onCursor: ({ line, col }) => { cursor.textContent = `LN ${line} COL ${col}`; },
|
||||
});
|
||||
if (load(key) === null) api.example('telemetry').then((t) => { editor.value = t; }).catch(() => {});
|
||||
|
||||
// ---- console -----------------------------------------------------------
|
||||
const output = h('div', { class: 'console', role: 'log', 'aria-live': 'polite' },
|
||||
@@ -33,14 +43,13 @@ export function codeView(ctx) {
|
||||
try {
|
||||
const r = await api.assemble(editor.value);
|
||||
editor.setError(null);
|
||||
const bytes = b64ToBytes(r.program);
|
||||
assembled = { bytes, ...r };
|
||||
assembled = { bytes: b64ToBytes(r.program), ...r };
|
||||
const pct = Math.min(100, Math.round((r.size / r.limit) * 100));
|
||||
say(
|
||||
h('p', { class: r.fits ? 'ok' : 'bad' }, r.fits ? 'ASSEMBLED OK' : (r.size === 0 ? 'NOTHING TO ASSEMBLE' : 'PROGRAM TOO LARGE FOR THIS BELT')),
|
||||
h('p', null, `${r.size} BYTES / ${r.instructions} INSTRUCTIONS / LIMIT ${r.limit} BYTES`),
|
||||
h('div', { class: `meter ${r.fits ? '' : 'over'}`, role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': String(r.limit), 'aria-valuenow': String(r.size), 'aria-label': 'Program size' },
|
||||
h('div', { class: 'fill', style: null, 'data-pct': String(pct) })),
|
||||
h('div', { class: 'fill' })),
|
||||
);
|
||||
output.querySelector('.fill')?.style.setProperty('width', `${pct}%`);
|
||||
return assembled;
|
||||
@@ -52,50 +61,138 @@ export function codeView(ctx) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- toolbar -----------------------------------------------------------
|
||||
const btn = (label, onclick, opts = {}) =>
|
||||
h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
|
||||
|
||||
const exampleSel = h('select', { id: 'example', 'aria-label': 'Example program' }, h('option', { value: '' }, 'EXAMPLES...'));
|
||||
api.examples().then((names) => {
|
||||
for (const n of names.filter((n) => n !== 'ports')) exampleSel.append(h('option', { value: n }, n.toUpperCase()));
|
||||
});
|
||||
exampleSel.addEventListener('change', async () => {
|
||||
const name = exampleSel.value;
|
||||
exampleSel.value = '';
|
||||
if (!name) return;
|
||||
if (editor.value.trim() && !(await confirmDialog({ title: 'REPLACE EDITOR CONTENTS?', body: `LOAD THE ${name.toUpperCase()} EXAMPLE OVER YOUR CURRENT TEXT.`, confirmText: 'REPLACE' }))) return;
|
||||
try { editor.value = await api.example(name); say(h('p', { class: 'dim' }, `LOADED ${name.toUpperCase()}.`)); } catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
|
||||
const bytesOrNull = async () => {
|
||||
const r = await assemble();
|
||||
if (r && r.size === 0) { toast('NOTHING TO ASSEMBLE', 'err'); return null; }
|
||||
return r;
|
||||
};
|
||||
|
||||
const seal = btn(ship ? `SEAL INTO SHIP #${ship.id}` : 'NO SHIP', async () => {
|
||||
const r = await bytesOrNull();
|
||||
if (!r) return;
|
||||
if (!r.fits) { toast(`PROGRAM IS ${r.size} BYTES; THE LIMIT IS ${r.limit}`, 'err'); return; }
|
||||
try {
|
||||
await api.setProgram(ship.id, r.bytes);
|
||||
toast(`PROGRAM SEALED IN SHIP #${ship.id} (${r.size} BYTES). READY TO LAUNCH.`);
|
||||
await ctx.refresh();
|
||||
} catch (e) { toast(`UPLOAD FAILED: ${e.message}`, 'err'); }
|
||||
}, { cls: 'primary', disabled: !canSeal, title: canSeal ? 'Store this program in the ship' : 'Only docked ships can be reprogrammed' });
|
||||
// ---- slots -------------------------------------------------------------
|
||||
const btn = (label, onclick, opts = {}) =>
|
||||
h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
|
||||
|
||||
const toolbar = h('div', { class: 'toolbar' },
|
||||
exampleSel,
|
||||
const slotSel = h('select', { id: 'slot', class: 'span2', 'aria-label': 'Code slot', disabled: true });
|
||||
const status = h('p', { class: 'hint slot-status', 'aria-live': 'polite' }, 'LOADING YOUR SLOTS...');
|
||||
const saveBtn = btn('SAVE', () => save(), { disabled: true });
|
||||
const saveAsBtn = btn('SAVE AS...', () => saveAs(), { disabled: true });
|
||||
const clearBtn = btn('CLEAR SLOT', () => clearSlot(), { cls: 'span2', disabled: true });
|
||||
|
||||
const dirty = () => editor.value !== current.saved;
|
||||
|
||||
function renderSelect() {
|
||||
slotSel.replaceChildren(...slots.map((s) => h('option', { value: s.slot, selected: s.slot === current.slot }, slotLabel(s))));
|
||||
slotSel.value = String(current.slot);
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
if (!ready) return;
|
||||
const s = info(current.slot);
|
||||
status.textContent = `SLOT ${current.slot}: ${s.builtin ? `${s.name} (EXAMPLE, READ-ONLY)` : s.empty ? 'EMPTY' : s.name}${dirty() ? ' // UNSAVED CHANGES' : ''}`;
|
||||
status.classList.toggle('warn', dirty());
|
||||
saveBtn.disabled = current.builtin;
|
||||
saveBtn.title = current.builtin ? 'The examples are read-only: use SAVE AS to keep a copy in your own slot' : `Save in slot ${current.slot}`;
|
||||
saveAsBtn.disabled = false;
|
||||
clearBtn.disabled = current.builtin || s.empty;
|
||||
}
|
||||
|
||||
async function openSlot(n, { keepText = null } = {}) {
|
||||
const s = await api.slot(n);
|
||||
current = { slot: n, name: s.name, builtin: s.builtin, saved: s.source };
|
||||
editor.value = keepText ?? s.source;
|
||||
writeJSON(draftKey, { slot: n, text: editor.value });
|
||||
renderSelect();
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
slotSel.addEventListener('change', async () => {
|
||||
const n = Number(slotSel.value);
|
||||
if (dirty() && !(await confirmDialog({
|
||||
title: 'DISCARD UNSAVED CHANGES?',
|
||||
body: `YOU HAVE CHANGES IN SLOT ${current.slot} THAT ARE NOT SAVED. OPENING SLOT ${n} REPLACES THEM.`,
|
||||
confirmText: 'DISCARD', danger: true,
|
||||
}))) { slotSel.value = String(current.slot); return; }
|
||||
try { await openSlot(n); say(h('p', { class: 'dim' }, `OPENED SLOT ${n}.`)); } catch (e) { toast(e.message, 'err'); slotSel.value = String(current.slot); }
|
||||
});
|
||||
|
||||
async function put(n, name) {
|
||||
try {
|
||||
await api.saveSlot(n, name, editor.value);
|
||||
slots = slots.map((s) => (s.slot === n ? { slot: n, name, builtin: false, empty: false } : s));
|
||||
current = { slot: n, name, builtin: false, saved: editor.value };
|
||||
writeJSON(draftKey, { slot: n, text: editor.value });
|
||||
renderSelect();
|
||||
renderStatus();
|
||||
toast(`SAVED IN SLOT ${n}: ${name}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast(`SAVE FAILED: ${e.message}`, 'err');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (current.builtin) return saveAs();
|
||||
if (!current.name) return saveAs();
|
||||
return put(current.slot, current.name);
|
||||
}
|
||||
|
||||
async function saveAs() {
|
||||
const writable = slots.filter((s) => !s.builtin);
|
||||
const firstFree = writable.find((s) => s.empty);
|
||||
const target = current.builtin ? (firstFree ?? writable[0]).slot : current.slot;
|
||||
const sel = h('select', { id: 'sa-slot' }, writable.map((s) => h('option', { value: s.slot, selected: s.slot === target }, slotLabel(s))));
|
||||
const name = h('input', { id: 'sa-name', type: 'text', maxlength: '40', value: current.name || 'MY PROGRAM', spellcheck: 'false' });
|
||||
const warn = h('p', { class: 'warn', 'aria-live': 'polite' });
|
||||
const dlg = customDialog({
|
||||
title: 'SAVE IN A SLOT',
|
||||
content: [h('label', { for: 'sa-slot' }, 'SLOT'), sel, h('label', { for: 'sa-name' }, 'NAME'), name, warn],
|
||||
confirmText: 'SAVE',
|
||||
});
|
||||
const check = () => {
|
||||
const s = info(Number(sel.value));
|
||||
warn.textContent = s.empty || s.slot === current.slot ? '' : `THIS REPLACES "${s.name}".`;
|
||||
dlg.confirm.disabled = !name.value.trim();
|
||||
};
|
||||
sel.addEventListener('change', check);
|
||||
name.addEventListener('input', check);
|
||||
name.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault(); // otherwise the same keystroke lands on the SAVE AS button that regains focus, and reopens this dialog
|
||||
if (!dlg.confirm.disabled) dlg.close(true);
|
||||
});
|
||||
check();
|
||||
name.select();
|
||||
if (await dlg.result) await put(Number(sel.value), name.value.trim());
|
||||
}
|
||||
|
||||
async function clearSlot() {
|
||||
const s = info(current.slot);
|
||||
if (!(await confirmDialog({ title: `CLEAR SLOT ${s.slot}?`, body: [`"${s.name}" WILL BE DELETED FROM THE SLOT.`, 'THE TEXT NOW IN THE EDITOR STAYS UNTIL YOU CHANGE SLOT.'], confirmText: 'CLEAR SLOT', danger: true }))) return;
|
||||
try {
|
||||
await api.deleteSlot(s.slot);
|
||||
slots = slots.map((x) => (x.slot === s.slot ? { slot: s.slot, name: '', builtin: false, empty: true } : x));
|
||||
current = { slot: s.slot, name: '', builtin: false, saved: '' };
|
||||
renderSelect();
|
||||
renderStatus();
|
||||
toast(`SLOT ${s.slot} CLEARED`);
|
||||
} catch (e) { toast(`CLEAR FAILED: ${e.message}`, 'err'); }
|
||||
}
|
||||
|
||||
// ---- bars --------------------------------------------------------------
|
||||
const bar = (label, ...buttons) =>
|
||||
h('div', { class: 'bar', role: 'group', 'aria-label': label }, h('span', { class: 'bar-label' }, label), h('div', { class: 'bar-buttons' }, ...buttons));
|
||||
|
||||
const slotBar = bar('SLOT', slotSel, saveBtn, saveAsBtn, clearBtn);
|
||||
const buildBar = bar('BUILD',
|
||||
btn('ASSEMBLE', assemble),
|
||||
btn('DOWNLOAD .BIN', async () => { const r = await bytesOrNull(); if (r) download(`slot-${current.slot}-program.bin`, r.bytes); }),
|
||||
);
|
||||
const fileBar = bar('FILE',
|
||||
btn('INSERT EQUATES', async () => {
|
||||
if (/\bP_UPNEW\b/.test(editor.value)) { toast('EQUATES ALREADY PRESENT'); return; }
|
||||
try { editor.insertAtTop(`${await api.example('ports')}\n`); } catch (e) { toast(e.message, 'err'); }
|
||||
}),
|
||||
h('span', { class: 'spacer' }),
|
||||
btn('ASSEMBLE', assemble),
|
||||
btn('DOWNLOAD .BIN', async () => { const r = await bytesOrNull(); if (r) download(`ship-${ship?.id ?? 'x'}-program.bin`, r.bytes); }),
|
||||
btn('SAVE .S', () => download(`ship-${ship?.id ?? 'x'}.s`, editor.value, 'text/plain')),
|
||||
btn('DOWNLOAD .S', () => download(`slot-${current.slot}.s`, editor.value, 'text/plain')),
|
||||
);
|
||||
|
||||
// ---- reference ---------------------------------------------------------
|
||||
@@ -104,36 +201,49 @@ export function codeView(ctx) {
|
||||
if (!e.target.open || card.dataset.loaded) return;
|
||||
card.dataset.loaded = '1';
|
||||
try {
|
||||
const txt = await (await fetch('/manual.txt')).text();
|
||||
const a = txt.indexOf('APPENDIX E QUICK REFERENCE CARD');
|
||||
const b = txt.indexOf('* * * END OF MANUAL');
|
||||
card.textContent = txt.slice(a, b).split('\n').slice(3).join('\n').trim();
|
||||
// Appendix E of the manual source is one fenced block: the quick reference card.
|
||||
const txt = await (await fetch('/manual.md')).text();
|
||||
const open = txt.indexOf('```', txt.indexOf('# Appendix E'));
|
||||
const close = txt.indexOf('```', open + 3);
|
||||
card.textContent = txt.slice(txt.indexOf('\n', open) + 1, close).trim();
|
||||
} catch { card.textContent = 'COULD NOT LOAD THE MANUAL.'; }
|
||||
} },
|
||||
h('summary', { class: 'panel-title' }, 'QUICK REFERENCE (HC-33 MANUAL, APPENDIX E)'),
|
||||
card,
|
||||
h('p', { class: 'hint pad' }, h('a', { href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL')),
|
||||
h('p', { class: 'hint pad' }, h('a', { href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL (PDF)')),
|
||||
);
|
||||
|
||||
const note = !ship
|
||||
? 'YOU HAVE NO SHIP TO PROGRAM.'
|
||||
: canSeal
|
||||
? `PROGRAMMING SHIP #${ship.id}. SEALING REPLACES ANY EARLIER PROGRAM UNTIL LAUNCH.`
|
||||
: `SHIP #${ship.id} IS ${ship.status.toUpperCase()}: ITS PROGRAM IS SEALED. YOU CAN STILL EDIT AND DOWNLOAD HERE.`;
|
||||
|
||||
const view = h('div', { class: 'stack-lg' },
|
||||
h('section', { class: 'panel' },
|
||||
h('div', { class: 'panel-title' }, 'FLIGHT PROGRAM EDITOR', h('span', { class: 'spacer' }), cursor),
|
||||
h('div', { class: 'panel-body' },
|
||||
toolbar,
|
||||
h('p', { class: 'hint' }, 'YOU HAVE 16 CODE SLOTS: 1-3 HOLD EXAMPLES, 4-16 ARE YOURS. TO FLY A PROGRAM, LAUNCH A SHIP FROM THE FLEET TAB AND CHOOSE ITS SLOT.'),
|
||||
slotBar,
|
||||
status,
|
||||
editor.el,
|
||||
h('div', { class: 'toolbar sealrow' }, h('p', { class: 'hint grow' }, note), seal),
|
||||
h('p', { class: 'hint' }, 'DRAFTS ARE KEPT IN THIS BROWSER ONLY. TAB INDENTS; ESC THEN TAB LEAVES THE EDITOR.'),
|
||||
h('p', { class: 'hint' }, 'YOUR UNSAVED TEXT IS KEPT IN THIS BROWSER. TAB INDENTS; ESC THEN TAB LEAVES THE EDITOR.'),
|
||||
buildBar,
|
||||
fileBar,
|
||||
output,
|
||||
),
|
||||
),
|
||||
ref,
|
||||
);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
slots = await api.slots();
|
||||
const draft = readJSON(draftKey);
|
||||
const start = draft && slots.some((s) => s.slot === draft.slot) ? draft.slot : DEFAULT_SLOT;
|
||||
ready = true;
|
||||
slotSel.disabled = false;
|
||||
await openSlot(start, { keepText: draft && draft.slot === start && typeof draft.text === 'string' ? draft.text : null });
|
||||
} catch (e) {
|
||||
status.textContent = `COULD NOT LOAD YOUR SLOTS: ${e.message}`;
|
||||
status.classList.add('bad');
|
||||
}
|
||||
})();
|
||||
|
||||
queueMicrotask(() => editor.render());
|
||||
return view;
|
||||
}
|
||||
|
||||
+59
-19
@@ -1,26 +1,70 @@
|
||||
// Fleet manifest with launch controls.
|
||||
|
||||
import { api } from './api.js';
|
||||
import { h, toast, confirmDialog } from './dom.js';
|
||||
import { h, toast, customDialog, pad } from './dom.js';
|
||||
import { statusOf } from './status.js';
|
||||
|
||||
export function fleetView(ctx) {
|
||||
const { ships, selected } = ctx.state;
|
||||
|
||||
async function launch(ship) {
|
||||
const ok = await confirmDialog({
|
||||
title: `LAUNCH SHIP #${ship.id}?`,
|
||||
body: [
|
||||
'THE SHIP ENTERS THE BELT ON THE NEXT DAILY RUN.',
|
||||
'ITS PROGRAM IS SEALED AT LAUNCH. IT CANNOT BE CHANGED, RECALLED OR RESTARTED.',
|
||||
let slots;
|
||||
try { slots = await api.slots(); } catch (e) { toast(`COULD NOT LOAD YOUR CODE SLOTS: ${e.message}`, 'err'); return; }
|
||||
const slotLabel = (s) => `${pad(s.slot, 2)} ${s.builtin ? `${s.name} (EXAMPLE)` : s.empty ? '(EMPTY)' : s.name}`;
|
||||
let remembered = 0;
|
||||
try { remembered = Number(localStorage.getItem('halcyon.launchSlot')); } catch { /* ignore */ }
|
||||
const usable = slots.filter((s) => !s.empty);
|
||||
const first = usable.find((s) => s.slot === remembered) ?? usable.find((s) => !s.builtin) ?? usable[0];
|
||||
|
||||
const sel = h('select', { id: 'launch-slot' },
|
||||
slots.map((s) => h('option', { value: s.slot, disabled: s.empty, selected: s.slot === first.slot }, slotLabel(s))));
|
||||
const status = h('p', { class: 'launch-status', role: 'status', 'aria-live': 'polite' }, 'CHECKING...');
|
||||
const dlg = customDialog({
|
||||
title: `LAUNCH SHIP #${ship.id}`,
|
||||
content: [
|
||||
h('p', null, 'CHOOSE THE CODE SLOT THIS SHIP WILL FLY. IT IS ASSEMBLED NOW AND SEALED INTO THE SHIP.'),
|
||||
h('label', { for: 'launch-slot' }, 'CODE SLOT'),
|
||||
sel,
|
||||
status,
|
||||
h('p', { class: 'warn' }, 'AT LAUNCH THE PROGRAM IS FIXED. IT CANNOT BE CHANGED, RECALLED OR RESTARTED.'),
|
||||
],
|
||||
confirmText: 'LAUNCH',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// Assemble the chosen slot as soon as it is chosen, so problems show before launch.
|
||||
let seq = 0;
|
||||
const check = async () => {
|
||||
const mine = ++seq;
|
||||
const n = Number(sel.value);
|
||||
dlg.confirm.disabled = true;
|
||||
status.className = 'launch-status';
|
||||
status.textContent = 'ASSEMBLING...';
|
||||
let text;
|
||||
let good = false;
|
||||
try {
|
||||
await api.launch(ship.id);
|
||||
toast(`SHIP #${ship.id} CLEARED FOR LAUNCH. IT FLIES ON THE NEXT DAILY RUN.`);
|
||||
const src = await api.slot(n);
|
||||
const r = await api.assemble(src.source);
|
||||
if (r.size === 0) text = 'THIS SLOT HAS NO CODE.';
|
||||
else if (!r.fits) text = `TOO LARGE: ${r.size} BYTES, THE LIMIT IS ${r.limit}.`;
|
||||
else { good = true; text = `${r.size} BYTES / ${r.instructions} INSTRUCTIONS: READY TO LAUNCH.`; }
|
||||
} catch (e) {
|
||||
text = `DOES NOT ASSEMBLE: ${e.message}`;
|
||||
}
|
||||
if (mine !== seq) return; // a newer choice superseded this one
|
||||
status.className = `launch-status ${good ? 'ok' : 'bad'}`;
|
||||
status.textContent = text;
|
||||
dlg.confirm.disabled = !good;
|
||||
};
|
||||
sel.addEventListener('change', check);
|
||||
check();
|
||||
|
||||
if (!(await dlg.result)) return;
|
||||
const n = Number(sel.value);
|
||||
try {
|
||||
const r = await api.launch(ship.id, n);
|
||||
try { localStorage.setItem('halcyon.launchSlot', String(n)); } catch { /* ignore */ }
|
||||
toast(`SHIP #${ship.id} CLEARED FOR LAUNCH WITH SLOT ${n} (${r?.program_bytes ?? '?'} BYTES). IT FLIES ON THE NEXT DAILY RUN.`);
|
||||
await ctx.refresh();
|
||||
} catch (e) {
|
||||
toast(`LAUNCH FAILED: ${e.message}`, 'err');
|
||||
@@ -34,11 +78,7 @@ export function fleetView(ctx) {
|
||||
const out = [];
|
||||
if (s.status === 'inventory') {
|
||||
out.push(btn('CODE', () => ctx.selectShip(s.id, 'code')));
|
||||
out.push(btn('LAUNCH', () => launch(s), {
|
||||
cls: 'primary',
|
||||
disabled: s.program_bytes === 0,
|
||||
title: s.program_bytes === 0 ? 'Upload a program first' : 'Launch on the next daily run',
|
||||
}));
|
||||
out.push(btn('LAUNCH', () => launch(s), { cls: 'primary', title: 'Choose a code slot and launch on the next daily run' }));
|
||||
}
|
||||
if (s.status === 'launching' || s.status === 'active') out.push(btn('UPLINK', () => ctx.selectShip(s.id, 'uplink')));
|
||||
if (s.downlink_day >= 0) out.push(btn('DOWNLINK', () => ctx.selectShip(s.id, 'downlink')));
|
||||
@@ -72,13 +112,13 @@ export function fleetView(ctx) {
|
||||
h('aside', { class: 'panel brief' },
|
||||
h('div', { class: 'panel-title' }, 'MISSION BRIEF'),
|
||||
h('ol', { class: 'steps' },
|
||||
step('1', 'WRITE', 'a flight program in the CODE editor. Your ship cannot be flown by hand.'),
|
||||
step('2', 'SEAL', 'it into a docked ship. You can replace it until launch.'),
|
||||
step('3', 'LAUNCH', 'the ship. It enters the belt on the next daily run and the program is fixed for good.'),
|
||||
step('1', 'WRITE', 'a flight program in the CODE editor and save it in one of your 16 code slots. Your ship cannot be flown by hand.'),
|
||||
step('2', 'TEST', 'it: assemble it in the editor, and improve it in as many slots as you like.'),
|
||||
step('3', 'LAUNCH', 'the ship and choose which slot it flies. It enters the belt on the next daily run and the program is fixed for good.'),
|
||||
step('4', 'TALK', 'to it once a day: 1 KB up, 1 KB down, over the wormhole link.'),
|
||||
),
|
||||
h('p', { class: 'hint pad' }, 'The belt is simulated once a day. Ore delivered to the station earns credits.'),
|
||||
h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL')),
|
||||
h('p', { class: 'hint pad' }, 'The belt runs once a day. Ore delivered to the station earns credits.'),
|
||||
h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL (PDF)')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function uplinkView(ctx) {
|
||||
send.disabled = !open || over || b.length === 0;
|
||||
clear(preview);
|
||||
if (b.length) {
|
||||
preview.append(hexView(b, { rows: 8 }).el);
|
||||
preview.append(hexView(b, { maxBytes: 128, maxFont: 13 }).el);
|
||||
if (b.length > 128) preview.append(h('p', { class: 'hint' }, `SHOWING THE FIRST 128 OF ${b.length} BYTES.`));
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function uplinkView(ctx) {
|
||||
h('div', { class: 'panel-title' }, 'OUTGOING MESSAGE'),
|
||||
h('div', { class: 'panel-body' },
|
||||
preview,
|
||||
h('p', { class: 'hint' }, 'THE MESSAGE IS OPAQUE BYTES. YOUR SHIP\'S PROGRAM DECIDES WHAT IT MEANS. IT ARRIVES IN THE UPLINK BUFFER (RAM 0-1023) BEFORE THE FIRST TICK OF THE NEXT DAILY RUN.'),
|
||||
h('p', { class: 'hint' }, 'THE MESSAGE IS OPAQUE BYTES. YOUR SHIP\'S PROGRAM DECIDES WHAT IT MEANS. IT ARRIVES IN THE UPLINK BUFFER (RAM 0-1023) AT THE START OF EACH DAY.'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+38
-7
@@ -6,11 +6,12 @@ import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"wh/docs"
|
||||
"wh/examples"
|
||||
"halcyon/docs"
|
||||
"halcyon/examples"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
@@ -18,15 +19,18 @@ var static embed.FS
|
||||
|
||||
// Handler serves the site. Mount it as the catch-all route; more specific API
|
||||
// routes registered on the same mux take precedence.
|
||||
func Handler() http.Handler {
|
||||
//
|
||||
// manualPath is the rendered PDF manual; it is opened on every request, so a
|
||||
// new render is served as soon as it is renamed into place, without a restart.
|
||||
func Handler(manualPath string) http.Handler {
|
||||
sub, _ := fs.Sub(static, "static")
|
||||
files := http.FileServerFS(sub)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /", files)
|
||||
mux.HandleFunc("GET /manual.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Write(docs.Manual)
|
||||
mux.HandleFunc("GET /manual.md", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Write(docs.Markdown)
|
||||
})
|
||||
mux.HandleFunc("GET /examples/index.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
entries, _ := fs.ReadDir(examples.FS, ".")
|
||||
@@ -41,7 +45,34 @@ func Handler() http.Handler {
|
||||
json.NewEncoder(w).Encode(names)
|
||||
})
|
||||
mux.Handle("GET /examples/", http.StripPrefix("/examples/", http.FileServerFS(examples.FS)))
|
||||
return secure(mux)
|
||||
|
||||
top := http.NewServeMux()
|
||||
top.HandleFunc("GET /manual.pdf", manualPDF(manualPath)) // not under the site's CSP: PDF viewers need their own resources
|
||||
top.Handle("/", secure(mux))
|
||||
return top
|
||||
}
|
||||
|
||||
// manualPDF serves the manual rendered separately from the app.
|
||||
func manualPDF(path string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
http.Error(w, "The PDF manual has not been rendered yet (run scripts/render-manual.sh). "+
|
||||
"The Markdown source is at /manual.md.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, "cannot read the PDF manual", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="halcyon-hc33-manual.pdf"`)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Cache-Control", "no-cache") // revalidate, so a fresh render shows up
|
||||
http.ServeContent(w, r, "manual.pdf", st.ModTime(), f) // ranges and If-Modified-Since
|
||||
}
|
||||
}
|
||||
|
||||
// secure adds headers appropriate to a page that loads only its own assets.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
// on a circle traversed at speed v; only the period (2*pi*r/v) varies.
|
||||
package world
|
||||
|
||||
import "wh/fixed"
|
||||
import "halcyon/fixed"
|
||||
|
||||
// Orbit is a circular orbit around the origin (the star).
|
||||
type Orbit struct {
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"wh/config"
|
||||
"wh/fixed"
|
||||
"wh/rng"
|
||||
"halcyon/config"
|
||||
"halcyon/fixed"
|
||||
"halcyon/rng"
|
||||
)
|
||||
|
||||
// Ore identifies a raw material.
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"wh/config"
|
||||
"wh/fixed"
|
||||
"halcyon/config"
|
||||
"halcyon/fixed"
|
||||
)
|
||||
|
||||
func TestOrbitsAreCirclesAtCommonSpeed(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user