This commit is contained in:
root
2026-09-20 14:16:41 +02:00
parent 0798933b05
commit b6d1909d7f
56 changed files with 3752 additions and 1989 deletions
+1
View File
@@ -7,3 +7,4 @@ Dockerfile
.dockerignore
web/e2e
node_modules
**/__pycache__
+22 -5
View File
@@ -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"]
+37 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)})
}
+247
View File
@@ -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
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"os"
"wh/vm"
"halcyon/vm"
)
func main() {
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
+9 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
+107
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-1556
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -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 ""))
+128
View File
@@ -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; }
+126
View File
@@ -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")
+248
View File
@@ -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 &amp; 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}")
+4 -4
View File
@@ -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 {
+2 -2
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -1,4 +1,4 @@
module wh
module halcyon
go 1.25.0
+1 -1
View File
@@ -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
View File
@@ -7,7 +7,7 @@ package rng
import (
"math/bits"
"wh/fixed"
"halcyon/fixed"
)
type R struct{ s [4]uint64 }
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"fmt"
"time"
"wh/config"
"wh/sim"
"wh/store"
"halcyon/config"
"halcyon/sim"
"halcyon/store"
)
const (
+2 -2
View File
@@ -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
+10
View File
@@ -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"
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+41
View File
@@ -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
View File
@@ -11,7 +11,7 @@ import (
"errors"
"fmt"
"wh/sim"
"halcyon/sim"
)
// Ship statuses.
+67
View File
@@ -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)
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+263
View File
@@ -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();
}
+27
View File
@@ -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 });
+73 -30
View File
@@ -31,50 +31,89 @@ 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;
const hexCells = [];
const ascCells = [];
for (let k = 0; k < 16; k++) {
const i = start + k;
if (i >= bytes.length) {
hexCells.push(h('span', { class: 'hx-b pad' }, ' '));
continue;
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 < n; k++) {
const i = start + k;
if (i >= total) {
hexCells.push(h('span', { class: 'hx-b pad' }, ' '));
continue;
}
const v = bytes[i];
const cls = v === 0 ? 'hx-b z' : 'hx-b';
const a = h('span', { class: `${cls} a`, 'data-i': i }, printable(v));
const x = h('span', { class: cls, 'data-i': i }, hex2(v));
cells.set(i, [x, a]);
hexCells.push(x);
ascCells.push(a);
if (n === 16 && k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' '));
}
const v = bytes[i];
const cls = v === 0 ? 'hx-b z' : 'hx-b';
const a = h('span', { class: `${cls} a`, 'data-i': i }, printable(v));
const x = h('span', { class: cls, 'data-i': i }, hex2(v));
cells.set(i, [x, a]);
hexCells.push(x);
ascCells.push(a);
if (k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' '));
rows.push(
h('div', { class: 'hx-row' },
h('span', { class: 'hx-off' }, pad(start.toString(16).toUpperCase(), 4)),
h('span', { class: 'hx-hex' }, hexCells),
h('span', { class: 'hx-asc' }, ascCells),
),
);
}
body.append(
h('div', { class: 'hx-row' },
h('span', { class: 'hx-off' }, pad(start.toString(16).toUpperCase(), 4)),
h('span', { class: 'hx-hex' }, hexCells),
h('span', { class: 'hx-asc' }, ascCells),
),
);
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 };
}
+1 -5
View File
@@ -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">
-90
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
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 {
await api.launch(ship.id);
toast(`SHIP #${ship.id} CLEARED FOR LAUNCH. IT FLIES ON THE NEXT DAILY RUN.`);
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)')),
),
);
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -4,8 +4,8 @@ import (
"math"
"testing"
"wh/config"
"wh/fixed"
"halcyon/config"
"halcyon/fixed"
)
func TestOrbitsAreCirclesAtCommonSpeed(t *testing.T) {