This commit is contained in:
root
2026-09-19 20:21:47 +02:00
commit 0798933b05
62 changed files with 7658 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.gitignore
*.db
*.db-shm
*.db-wal
Dockerfile
.dockerignore
web/e2e
node_modules
+43
View File
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1
# ---- deps: module download, cached until go.mod/go.sum change ----------------
FROM golang:1.25-alpine AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
# ---- test: `docker build --target test .` runs the test suite ----------------
FROM deps AS test
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go vet ./... && go test ./...
# ---- build: static binaries (SQLite and Postgres drivers are pure Go) --------
FROM deps AS build
COPY . .
ENV CGO_ENABLED=0
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags="-s -w" -o /out/ ./cmd/server ./cmd/daily ./cmd/asm
# ---- 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
COPY --from=build /out/server /out/daily /out/asm /usr/local/bin/
USER wh
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
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:
# docker run --rm -e DATABASE_URL=... <image> daily
CMD ["server"]
+65
View File
@@ -0,0 +1,65 @@
# Halcyon belt
A once-a-day asteroid-belt simulator. 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.
- `docs/manual.txt` -- the HC-33 flight computer programmer's manual (also served at `/manual.txt`)
- `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)
```
`-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
`daily`, and cannot be changed for an existing database.
Open <http://localhost:8080/>, create an account (you are shown an access key once;
it is your login), write a program, seal it into your ship, launch it, then run `daily`.
## Docker
```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 build --target test . # run the tests
```
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:
```sh
docker compose up -d --build
scripts/daily.sh # one simulated day 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
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`.
## Layout
| path | what |
|---|---|
| `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 |
| `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
test against PostgreSQL (it wipes that database's `public` schema).
+289
View File
@@ -0,0 +1,289 @@
// Package api exposes the player-facing HTTP interface.
package api
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"wh/comms"
"wh/config"
"wh/runner"
"wh/store"
"wh/vm"
"wh/world"
)
type Server struct {
st *store.Store
cfg config.Config
}
// New returns the HTTP handler.
//
// POST /register {"name": "..."} -> player, api_key, ship_id
// GET /me -> name, credits, day
// GET /market -> ore prices
// GET /info -> day and the belt's limits
// 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)
// PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink)
// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header
//
// Authenticated routes take "Authorization: Bearer <api_key>".
func New(st *store.Store, cfg config.Config) *http.ServeMux {
s := &Server{st: st, cfg: cfg}
mux := http.NewServeMux()
mux.HandleFunc("POST /register", s.register)
mux.HandleFunc("GET /market", s.market)
mux.HandleFunc("GET /info", s.info)
mux.HandleFunc("POST /assemble", s.assemble)
mux.Handle("GET /me", s.auth(s.me))
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("PUT /ships/{id}/uplink", s.auth(s.uplink))
mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink))
return mux
}
func (s *Server) auth(h func(http.ResponseWriter, *http.Request, store.Player)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
httpError(w, http.StatusUnauthorized, "missing bearer token")
return
}
p, err := s.st.PlayerByKey(r.Context(), key)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
httpError(w, http.StatusUnauthorized, "invalid api key")
} else {
fail(w, err)
}
return
}
h(w, r, p)
})
}
func httpError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
// fail maps store errors to HTTP statuses.
func fail(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
httpError(w, http.StatusNotFound, "not found")
case errors.Is(err, store.ErrState):
httpError(w, http.StatusConflict, err.Error())
case errors.Is(err, store.ErrTaken):
httpError(w, http.StatusConflict, err.Error())
default:
httpError(w, http.StatusInternalServerError, "internal error")
}
}
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<10)).Decode(&req); err != nil || len(req.Name) == 0 || len(req.Name) > 40 {
httpError(w, http.StatusBadRequest, `body must be {"name": "<1-40 chars>"}`)
return
}
p, key, ship, err := s.st.Register(r.Context(), req.Name)
if err != nil {
fail(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"player_id": p.ID, "api_key": key, "ship_id": ship})
}
func (s *Server) currentDay(ctx context.Context) int64 {
v, ok, _ := s.st.GetMeta(ctx, runner.MetaDay)
if !ok {
return 0
}
n, _ := strconv.ParseInt(v, 10, 64)
return n
}
func (s *Server) me(w http.ResponseWriter, r *http.Request, p store.Player) {
writeJSON(w, http.StatusOK, map[string]any{"name": p.Name, "credits": p.Credits, "day": s.currentDay(r.Context())})
}
func (s *Server) market(w http.ResponseWriter, r *http.Request) {
v, ok, err := s.st.GetMeta(r.Context(), runner.MetaMarket)
if err != nil {
fail(w, err)
return
}
if !ok {
v = "null"
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, v)
}
// info reports the day and the limits that apply to programs and messages.
func (s *Server) info(w http.ResponseWriter, r *http.Request) {
ores := make([]string, world.NumOre)
for o := range ores {
ores[o] = world.Ore(o).String()
}
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,
"program_bytes": s.cfg.ProgramBytes,
"ram_bytes": s.cfg.RAMBytes,
"comm_bytes": s.cfg.CommBytes,
"ores": ores,
})
}
// assemble compiles assembly source with the same assembler as cmd/asm, so
// that clients need no assembler of their own. Nothing is stored.
func (s *Server) assemble(w http.ResponseWriter, r *http.Request) {
b, ok := readBody(w, r, 64<<10)
if !ok {
return
}
prog, err := vm.Assemble(string(b))
if err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"size": len(prog),
"instructions": len(prog) / 4,
"limit": s.cfg.ProgramBytes,
"fits": len(prog) > 0 && len(prog) <= s.cfg.ProgramBytes,
"program": base64.StdEncoding.EncodeToString(prog),
})
}
func (s *Server) ships(w http.ResponseWriter, r *http.Request, p store.Player) {
ships, err := s.st.PlayerShips(r.Context(), p.ID)
if err != nil {
fail(w, err)
return
}
type out struct {
ID int64 `json:"id"`
Status string `json:"status"`
ProgramSize int `json:"program_bytes"`
DownlinkDay int64 `json:"downlink_day"`
}
res := make([]out, 0, len(ships))
for _, sh := range ships {
res = append(res, out{sh.ID, sh.Status, sh.ProgramSize, sh.DownlinkDay})
}
writeJSON(w, http.StatusOK, res)
}
func shipID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
httpError(w, http.StatusBadRequest, "bad ship id")
return 0, false
}
return id, true
}
// readBody reads at most limit bytes and rejects larger bodies.
func readBody(w http.ResponseWriter, r *http.Request, limit int) ([]byte, bool) {
b, err := io.ReadAll(io.LimitReader(r.Body, int64(limit)+1))
if err != nil || len(b) > limit {
httpError(w, http.StatusRequestEntityTooLarge, "body too large")
return nil, false
}
return b, true
}
func (s *Server) program(w http.ResponseWriter, r *http.Request, p store.Player) {
id, ok := shipID(w, r)
if !ok {
return
}
b, ok := readBody(w, r, s.cfg.ProgramBytes)
if !ok {
return
}
if err := comms.CheckProgram(b, s.cfg.ProgramBytes); err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.st.SetProgram(r.Context(), p.ID, id, b); err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player) {
id, ok := shipID(w, r)
if !ok {
return
}
if err := s.st.Launch(r.Context(), p.ID, id); err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusAccepted)
}
func (s *Server) uplink(w http.ResponseWriter, r *http.Request, p store.Player) {
id, ok := shipID(w, r)
if !ok {
return
}
b, ok := readBody(w, r, s.cfg.CommBytes)
if !ok {
return
}
if err := comms.CheckUplink(b, s.cfg.CommBytes); err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.st.SetUplink(r.Context(), p.ID, id, b); err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) downlink(w http.ResponseWriter, r *http.Request, p store.Player) {
id, ok := shipID(w, r)
if !ok {
return
}
data, day, err := s.st.Downlink(r.Context(), p.ID, id)
if err != nil {
fail(w, err)
return
}
if day < 0 {
httpError(w, http.StatusNotFound, "no downlink yet")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Downlink-Day", strconv.FormatInt(day, 10))
w.Write(data)
}
+160
View File
@@ -0,0 +1,160 @@
package api_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"wh/api"
"wh/config"
"wh/runner"
"wh/sim"
"wh/store"
"wh/vm"
)
func TestEndToEnd(t *testing.T) {
ctx := context.Background()
cfg := config.Default()
cfg.AsteroidCount = 20
// Set WH_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 != "" {
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)
}
defer st.Close()
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(api.New(st, cfg))
defer srv.Close()
var key string
do := func(method, path string, body []byte, want int) []byte {
t.Helper()
req, _ := http.NewRequest(method, srv.URL+path, bytes.NewReader(body))
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != want {
t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, b)
}
return b
}
var reg struct {
APIKey string `json:"api_key"`
ShipID int64 `json:"ship_id"`
}
json.Unmarshal(do("POST", "/register", []byte(`{"name":"ann"}`), 201), &reg)
do("POST", "/register", []byte(`{"name":"ann"}`), 409)
do("GET", "/me", nil, 401)
key = reg.APIKey
ship := "/ships/" + itoa(reg.ShipID)
// Launch needs a program first; program limits are enforced.
do("POST", ship+"/launch", nil, 409)
do("PUT", ship+"/program", []byte{1, 2, 3}, 400)
do("PUT", ship+"/program", make([]byte, cfg.ProgramBytes+4), 413)
// Echo the uplink back on the downlink, then idle.
prog, err := vm.Assemble(`
.equ TX 1024
in r1, 0x70
ldi r2, 0
beq r1, r2, idle
ldw r3, [r2]
stw r3, [r2+TX]
out 0x70, r1
idle:
yield
jmp idle
`)
if err != nil {
t.Fatal(err)
}
do("PUT", ship+"/program", prog, 204)
do("PUT", ship+"/uplink", []byte("hi"), 409) // not launched yet
do("POST", ship+"/launch", nil, 202)
do("PUT", ship+"/program", prog, 409) // frozen once launching
do("PUT", ship+"/uplink", []byte("PING"), 204)
do("PUT", ship+"/uplink", make([]byte, cfg.CommBytes+1), 413)
do("GET", ship+"/downlink", nil, 404)
res, err := runner.RunNextDay(ctx, st, cfg)
if err != nil {
t.Fatal(err)
}
if res.Day != 0 {
t.Fatalf("first run should be day 0, got %d", res.Day)
}
if b := do("GET", ship+"/downlink", nil, 200); len(b) != cfg.CommBytes || string(b[:4]) != "PING" {
t.Fatalf("downlink = %q", b[:8])
}
var ships []struct{ Status string }
json.Unmarshal(do("GET", "/ships", nil, 200), &ships)
if len(ships) != 1 || ships[0].Status != store.StatusActive {
t.Fatalf("ships = %+v", ships)
}
do("GET", "/market", nil, 200)
// A second day from the persisted snapshot must match an uninterrupted
// in-memory run: persistence may not change the simulation.
res2, err := runner.RunNextDay(ctx, st, cfg)
if err != nil {
t.Fatal(err)
}
mem := sim.NewState(cfg)
in := sim.DayInput{
Launches: []sim.Launch{{ShipID: reg.ShipID, Owner: 1, Program: prog}},
Uplinks: []sim.Uplink{{ShipID: reg.ShipID, Data: []byte("PING")}},
}
var want sim.DayResult
for d := 0; d < 2; d++ {
if want, err = mem.RunDay(cfg, in); err != nil {
t.Fatal(err)
}
in = sim.DayInput{}
}
if res2.Hash != want.Hash {
t.Fatal("state after DB round-trip differs from in-memory run")
}
// Changing the config on an existing world is refused.
cfg.Seed = 99
if _, err := runner.RunNextDay(ctx, st, cfg); err == nil {
t.Fatal("expected config mismatch error")
}
}
func itoa(n int64) string {
b, _ := json.Marshal(n)
return string(b)
}
+115
View File
@@ -0,0 +1,115 @@
package api_test
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"wh/api"
"wh/config"
"wh/store"
"wh/web"
)
// newSite starts the API with the web front end mounted, as cmd/server does.
func newSite(t *testing.T) (*httptest.Server, config.Config) {
t.Helper()
cfg := config.Default()
st, err := store.Open("sqlite:" + filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
mux := api.New(st, cfg)
mux.Handle("/", web.Handler())
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, cfg
}
func get(t *testing.T, url string) (*http.Response, string) {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp, string(b)
}
func TestInfoAndAssemble(t *testing.T) {
srv, cfg := newSite(t)
_, body := get(t, srv.URL+"/info")
var info struct {
ProgramBytes int `json:"program_bytes"`
CommBytes int `json:"comm_bytes"`
Ores []string `json:"ores"`
}
if err := json.Unmarshal([]byte(body), &info); err != nil {
t.Fatal(err)
}
if info.ProgramBytes != cfg.ProgramBytes || info.CommBytes != cfg.CommBytes || len(info.Ores) != 4 {
t.Fatalf("info = %+v", info)
}
post := func(src string) (int, map[string]any) {
resp, err := http.Post(srv.URL+"/assemble", "text/plain", strings.NewReader(src))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var m map[string]any
json.NewDecoder(resp.Body).Decode(&m)
return resp.StatusCode, m
}
code, m := post("ldi r1, 5\nhalt")
if code != 200 || m["size"].(float64) != 8 || m["fits"] != true {
t.Fatalf("assemble ok case: %d %v", code, m)
}
if raw, _ := base64.StdEncoding.DecodeString(m["program"].(string)); len(raw) != 8 {
t.Fatalf("program bytes = %d", len(raw))
}
code, m = post("ldi r1, 5\nbogus r1")
if code != 400 || !strings.Contains(m["error"].(string), "line 2") {
t.Fatalf("assemble error case: %d %v", code, m)
}
// Too big for the belt: assembles, but reports that it does not fit.
code, m = post(strings.Repeat("nop\n", cfg.ProgramBytes/4+1))
if code != 200 || m["fits"] != false {
t.Fatalf("oversize case: %d %v", code, m)
}
}
func TestWebAssets(t *testing.T) {
srv, _ := newSite(t)
for path, want := range map[string]string{
"/": "<title>",
"/manual.txt": "PROGRAMMER'S REFERENCE MANUAL",
"/examples/echo.s": "ECHO",
"/examples/index.json": `"pursue"`,
} {
resp, body := get(t, srv.URL+path)
if resp.StatusCode != 200 || !strings.Contains(body, want) {
t.Errorf("%s: status %d, missing %q", path, resp.StatusCode, want)
}
}
resp, _ := get(t, srv.URL+"/")
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") {
t.Error("missing CSP header")
}
// 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"))
}
}
+8
View File
@@ -0,0 +1,8 @@
# TLS certificates are obtained and renewed automatically (Let's Encrypt).
# Requirements: DNS for the name below points at this host, and ports 80 and
# 443 are reachable from the internet (80 is used for the ACME challenge and
# redirects to HTTPS).
sandbox.clearsky.dev {
encode zstd gzip
reverse_proxy web:8080
}
+28
View File
@@ -0,0 +1,28 @@
// Command asm assembles ship-program source into the binary that the
// program endpoint accepts: asm prog.s > prog.bin
package main
import (
"fmt"
"os"
"wh/vm"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: asm <source.s> > program.bin")
os.Exit(2)
}
src, err := os.ReadFile(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
prog, err := vm.Assemble(string(src))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
os.Stdout.Write(prog)
}
+41
View File
@@ -0,0 +1,41 @@
// Command daily runs one simulated day against the database. Schedule it
// once a day (cron/systemd timer).
package main
import (
"context"
"flag"
"fmt"
"log"
"wh/config"
"wh/runner"
"wh/store"
)
func main() {
dsn := flag.String("db", config.DatabaseURL(), "sqlite:<path> or postgres:// URL (default: $DATABASE_URL)")
cfg, err := config.Bind(flag.CommandLine)
if err != nil {
log.Fatal(err)
}
flag.Parse()
ctx := context.Background()
st, err := store.Open(*dsn)
if err != nil {
log.Fatal(err)
}
defer st.Close()
if err := st.Migrate(ctx); err != nil {
log.Fatal(err)
}
res, err := runner.RunNextDay(ctx, st, *cfg)
if err != nil {
log.Fatal(err)
}
fmt.Printf("day %d complete: %d events, %d downlinks, hash %x\n", res.Day, len(res.Events), len(res.Downlinks), res.Hash[:8])
for _, e := range res.Events {
fmt.Println(" ", e)
}
}
+40
View File
@@ -0,0 +1,40 @@
// Command server serves the player HTTP API and the web front end.
package main
import (
"context"
"flag"
"log"
"net/http"
"wh/api"
"wh/config"
"wh/store"
"wh/web"
)
func main() {
dsn := flag.String("db", config.DatabaseURL(), "sqlite:<path> or postgres:// URL (default: $DATABASE_URL)")
addr := flag.String("addr", ":8080", "listen address")
cfg, err := config.Bind(flag.CommandLine)
if err != nil {
log.Fatal(err)
}
flag.Parse()
if err := cfg.Validate(); err != nil {
log.Fatal(err)
}
st, err := store.Open(*dsn)
if err != nil {
log.Fatal(err)
}
defer st.Close()
if err := st.Migrate(context.Background()); err != nil {
log.Fatal(err)
}
mux := api.New(st, *cfg)
mux.Handle("/", web.Handler()) // the site; API routes are more specific and win
log.Printf("listening on %s", *addr)
log.Fatal(http.ListenAndServe(*addr, mux))
}
+25
View File
@@ -0,0 +1,25 @@
// Package comms validates the data that crosses the wormhole link. The
// content of uplinks and downlinks is opaque bytes defined by the player's
// ship program; only sizes are enforced.
package comms
import "fmt"
func CheckUplink(data []byte, limit int) error {
if len(data) > limit {
return fmt.Errorf("uplink is %d bytes, limit is %d", len(data), limit)
}
return nil
}
func CheckProgram(prog []byte, limit int) error {
switch {
case len(prog) == 0:
return fmt.Errorf("program is empty")
case len(prog)%4 != 0:
return fmt.Errorf("program length %d is not a multiple of 4", len(prog))
case len(prog) > limit:
return fmt.Errorf("program is %d bytes, limit is %d", len(prog), limit)
}
return nil
}
+145
View File
@@ -0,0 +1,145 @@
// Package config holds tunable simulation parameters.
//
// Units used throughout the simulation: distance in kilometres, time in
// seconds, mass in kilograms, force in newtons. Angles are radians.
package config
import (
"flag"
"fmt"
"os"
"strconv"
"wh/fixed"
)
const SecondsPerDay = 86400
type Config struct {
// Seed identifies the world. The same seed and inputs always produce the
// same simulation.
Seed uint64
// Time resolution.
TicksPerDay int // must divide SecondsPerDay
CyclesPerTick int // VM instructions each ship may execute per tick
// Ship computer sizes.
ProgramBytes int // max program size
RAMBytes int // data RAM, includes comm buffers
CommBytes int // size of each of the uplink and downlink buffers
// World.
OrbitSpeed fixed.F // km/s; speed of every circular orbit (star pulls with v^2/r)
AsteroidCount int
BeltInner fixed.F // km
BeltOuter fixed.F // km
MaxInclination fixed.F // radians; asteroid inclinations are drawn up to this
}
func Default() Config {
return Config{
Seed: 1,
TicksPerDay: 1440,
CyclesPerTick: 2000,
ProgramBytes: 4096,
RAMBytes: 8192,
CommBytes: 1024,
OrbitSpeed: fixed.FromInt(3),
AsteroidCount: 500,
BeltInner: fixed.FromInt(1_500_000),
BeltOuter: fixed.FromInt(3_000_000),
MaxInclination: fixed.FromRatio(1, 5), // ~11.5 degrees
}
}
// TickSeconds is the simulated duration of one tick.
func (c Config) TickSeconds() int { return SecondsPerDay / c.TicksPerDay }
func (c Config) Validate() error {
switch {
case c.TicksPerDay <= 0 || SecondsPerDay%c.TicksPerDay != 0:
return fmt.Errorf("TicksPerDay must divide %d, got %d", SecondsPerDay, c.TicksPerDay)
case c.CyclesPerTick <= 0:
return fmt.Errorf("CyclesPerTick must be positive")
case c.ProgramBytes <= 0 || c.ProgramBytes%4 != 0:
return fmt.Errorf("ProgramBytes must be a positive multiple of 4")
case c.CommBytes <= 0 || c.RAMBytes < 2*c.CommBytes:
return fmt.Errorf("RAMBytes must hold both comm buffers")
case c.MaxInclination < 0 || c.MaxInclination > fixed.HalfPi:
return fmt.Errorf("MaxInclination must be within [0, pi/2]")
case c.OrbitSpeed <= 0:
return fmt.Errorf("OrbitSpeed must be positive")
case c.BeltInner <= 0 || c.BeltOuter <= c.BeltInner:
return fmt.Errorf("invalid belt radii")
}
return nil
}
// Bind registers a flag for each tunable on fs and returns the config that
// the flags will fill in when fs is parsed. Each tunable can also be set from
// an environment variable (see EnvVars); a flag on the command line overrides
// the environment. A malformed environment value is an error.
func Bind(fs *flag.FlagSet) (*Config, error) {
c := Default()
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)")
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",
}
func (c *Config) applyEnv(get func(string) string) error {
var err error
num := func(name string, dst any) {
v := get(name)
if v == "" || err != nil {
return
}
switch d := dst.(type) {
case *uint64:
var n uint64
if n, err = strconv.ParseUint(v, 10, 64); err == nil {
*d = n
}
case *int:
var n int
if n, err = strconv.Atoi(v); err == nil {
*d = n
}
}
if err != nil {
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)
return err
}
// DatabaseURL returns the default database location: $DATABASE_URL if set,
// else a SQLite file in the working directory. The -db flag overrides it.
// Accepted forms are "sqlite:<path>" and "postgres://...".
func DatabaseURL() string {
if v := os.Getenv("DATABASE_URL"); v != "" {
return v
}
return "sqlite:wh.db"
}
+73
View File
@@ -0,0 +1,73 @@
package config
import (
"flag"
"testing"
)
func bind(t *testing.T, args ...string) (*Config, error) {
t.Helper()
fs := flag.NewFlagSet("t", flag.ContinueOnError)
c, err := Bind(fs)
if err != nil {
return nil, err
}
if err := fs.Parse(args); err != nil {
t.Fatal(err)
}
return c, nil
}
func TestBindDefaults(t *testing.T) {
c, err := bind(t)
if err != nil || *c != Default() {
t.Fatalf("got %+v, %v", c, err)
}
}
func TestBindEnvAndFlagPrecedence(t *testing.T) {
t.Setenv("WH_SEED", "42")
t.Setenv("WH_TICKS_PER_DAY", "720")
t.Setenv("WH_ASTEROIDS", "60")
c, err := bind(t)
if err != nil {
t.Fatal(err)
}
if c.Seed != 42 || c.TicksPerDay != 720 || c.AsteroidCount != 60 || c.CyclesPerTick != Default().CyclesPerTick {
t.Fatalf("env not applied: %+v", c)
}
if err := c.Validate(); err != nil {
t.Fatal(err)
}
c, err = bind(t, "-seed", "7")
if err != nil || c.Seed != 7 || c.TicksPerDay != 720 {
t.Fatalf("flag should override env only for itself: %+v, %v", c, err)
}
}
func TestBindRejectsBadEnv(t *testing.T) {
for _, v := range []string{"lots", "-3", "1.5"} {
t.Setenv("WH_SEED", v)
if _, err := bind(t); err == nil {
t.Errorf("WH_SEED=%q should be rejected", v)
}
}
t.Setenv("WH_SEED", "")
t.Setenv("WH_COMM_BYTES", "big")
if _, err := bind(t); err == nil {
t.Error("WH_COMM_BYTES=big should be rejected")
}
}
func TestDatabaseURL(t *testing.T) {
t.Setenv("DATABASE_URL", "")
if DatabaseURL() != "sqlite:wh.db" {
t.Fatal("default")
}
t.Setenv("DATABASE_URL", "postgres://x")
if DatabaseURL() != "postgres://x" {
t.Fatal("env")
}
}
+45
View File
@@ -0,0 +1,45 @@
services:
web:
build: .
image: halcyon:latest
# A fixed name lets scripts/daily.sh find the container with `docker exec`.
container_name: halcyon
restart: unless-stopped
ports:
# Local only: the public entrance is Caddy, which terminates TLS.
- "127.0.0.1:8070:8080"
environment:
DATABASE_URL: sqlite:/data/wh.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}
volumes:
- halcyon-data:/data
command: ["server"]
caddy:
image: caddy:2-alpine
container_name: halcyon-caddy
restart: unless-stopped
depends_on:
- web
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data # certificates: keep this, or you will hit rate limits
- caddy-config:/config
volumes:
halcyon-data:
caddy-data:
caddy-config:
+7
View File
@@ -0,0 +1,7 @@
// Package docs embeds the HC-33 programmer's manual.
package docs
import _ "embed"
//go:embed manual.txt
var Manual []byte
+1556
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
// Package examples embeds the sample ship programs so the web site can offer
// them in its editor.
package examples
import (
"embed"
"io/fs"
)
//go:embed programs/*.s
var embedded embed.FS
// FS holds the programs, one file per program, at its root.
var FS = mustSub()
func mustSub() fs.FS {
sub, err := fs.Sub(embedded, "programs")
if err != nil {
panic(err)
}
return sub
}
+124
View File
@@ -0,0 +1,124 @@
package examples_test
import (
"encoding/binary"
"math"
"os"
"testing"
"wh/config"
"wh/fixed"
"wh/sim"
"wh/vm"
)
func asm(t *testing.T, name string) []byte {
t.Helper()
src, err := os.ReadFile("programs/" + name)
if err != nil {
t.Fatal(err)
}
p, err := vm.Assemble(string(src))
if err != nil {
t.Fatalf("%s: %v", name, err)
}
return p
}
func cfg() config.Config {
c := config.Default()
c.AsteroidCount = 50
return c
}
func launch(t *testing.T, c config.Config, prog []byte, uplink []byte) (*sim.State, sim.DayResult) {
t.Helper()
s := sim.NewState(c)
in := sim.DayInput{Launches: []sim.Launch{{ShipID: 1, Owner: 1, Program: prog}}}
if uplink != nil {
in.Uplinks = []sim.Uplink{{ShipID: 1, Data: uplink}}
}
res, err := s.RunDay(c, in)
if err != nil {
t.Fatal(err)
}
return s, res
}
func TestEcho(t *testing.T) {
msg := make([]byte, 300)
for i := range msg {
msg[i] = byte(i*7 + 1)
}
_, res := launch(t, cfg(), asm(t, "echo.s"), msg)
got := res.Downlinks[0].Data
for i := range msg {
if got[i] != msg[i] {
t.Fatalf("byte %d: got %d want %d", i, got[i], msg[i])
}
}
if got[300] != 0 {
t.Fatal("echoed too much")
}
}
func TestTelemetry(t *testing.T) {
c := cfg()
s, res := launch(t, c, asm(t, "telemetry.s"), nil)
d := res.Downlinks[0].Data
w := func(i int) int32 { return int32(binary.LittleEndian.Uint32(d[i*4:])) }
if w(0) != int32(c.TicksPerDay-1) || w(1) != 4000 {
t.Fatalf("tick=%d fuel=%d", w(0), w(1))
}
// The last report was taken one tick (<= ~180 km) before the final state.
end := s.Ships[0].Pos
for i, got := range []int32{w(2), w(3), w(4)} {
want := []fixed.F{end.X, end.Y, end.Z}[i].Floor()
if math.Abs(float64(int64(got)-want)) > 200 {
t.Fatalf("axis %d: reported %d, ship at %d", i, got, want)
}
}
}
func TestPursueAims(t *testing.T) {
c := cfg()
c.TicksPerDay = 1 // one long tick: the program aims once, from t=0 state
s := sim.NewState(c)
stPos, _ := s.Station.Orbit.State(0)
// Expected: nearest asteroid, and the bearing to it in whole km.
best, bestD := 0, fixed.Max
for i, a := range s.Asteroids {
p, _ := a.Orbit.State(0)
if d := p.Sub(stPos).Len(); d < bestD {
best, bestD = i, d
}
}
tp, _ := s.Asteroids[best].Orbit.State(0)
rel := tp.Sub(stPos)
dx, dy, dz := float64(rel.X.Floor()), float64(rel.Y.Floor()), float64(rel.Z.Floor())
wantAz := math.Atan2(dy, dx) * 1000
wantEl := math.Atan2(dz, math.Hypot(dx, dy)) * 1000
if _, err := s.RunDay(c, sim.DayInput{Launches: []sim.Launch{{ShipID: 1, Owner: 1, Program: asm(t, "pursue.s")}}}); err != nil {
t.Fatal(err)
}
sh := s.Ships[0]
if sh.Target != int64(best+1) || sh.Throttle != 1000 {
t.Fatalf("target=%d (want %d) throttle=%d", sh.Target, best+1, sh.Throttle)
}
if math.Abs(float64(sh.Azimuth)-wantAz) > 3 || math.Abs(float64(sh.Pitch)-wantEl) > 3 {
t.Fatalf("aim az=%d el=%d, want %.0f %.0f", sh.Azimuth, sh.Pitch, wantAz, wantEl)
}
}
func TestPortsEquatesAssemble(t *testing.T) {
// The standard equate file must assemble and be usable alongside code.
src, err := os.ReadFile("programs/ports.s")
if err != nil {
t.Fatal(err)
}
if _, err := vm.Assemble(string(src) + "\n in r1, P_UPNEW\n out P_THROTTLE, r1\n halt\n"); err != nil {
t.Fatal(err)
}
}
+20
View File
@@ -0,0 +1,20 @@
; ECHO -- copy each day's uplink into the downlink buffer.
;
.equ RX 0 ; uplink buffer
.equ TX 1024 ; downlink buffer
.equ P_UPNEW 0x70 ; uplink-arrived flag / acknowledge
.equ P_UPLEN 0x71 ; uplink length in bytes
wait: in r1, P_UPNEW
ldi r2, 0
beq r1, r2, sleep ; nothing new today
in r4, P_UPLEN
ldi r5, 0 ; r5 = byte index
copy: bge r5, r4, done
ldb r6, [r5+RX]
stb r6, [r5+TX]
addi r5, 1
jmp copy
done: out P_UPNEW, r1 ; acknowledge
sleep: yield
jmp wait
+57
View File
@@ -0,0 +1,57 @@
; PORTS -- standard equate file for the HC-33 (manual, Appendix C).
; Paste at the top of a program; unused equates cost nothing.
;
.equ RX 0 ; uplink buffer
.equ TX 1024 ; downlink buffer
.equ RAMTOP 8192 ; initial stack pointer
.equ P_TICK 0x00
.equ P_DAY 0x01
.equ P_TICKS 0x02
.equ P_POSX 0x10
.equ P_POSY 0x11
.equ P_POSZ 0x12
.equ P_VELX 0x13
.equ P_VELY 0x14
.equ P_VELZ 0x15
.equ P_THROTTLE 0x20
.equ P_AZIMUTH 0x21
.equ P_PITCH 0x22
.equ P_FUEL 0x23
.equ P_MASS 0x24
.equ P_SELECT 0x30
.equ P_NEAREST 0x31
.equ P_RELX 0x32
.equ P_RELY 0x33
.equ P_RELZ 0x34
.equ P_RELVX 0x35
.equ P_RELVY 0x36
.equ P_RELVZ 0x37
.equ P_DIST 0x38
.equ P_ORE0 0x40
.equ P_ORE1 0x41
.equ P_ORE2 0x42
.equ P_ORE3 0x43
.equ P_MINE 0x50
.equ P_CARGO 0x51
.equ P_CARGOCAP 0x52
.equ P_CARGO0 0x58
.equ P_CARGO1 0x59
.equ P_CARGO2 0x5A
.equ P_CARGO3 0x5B
.equ P_STNX 0x60
.equ P_STNY 0x61
.equ P_STNZ 0x62
.equ P_STNVX 0x63
.equ P_STNVY 0x64
.equ P_STNVZ 0x65
.equ P_SELL 0x66
.equ P_EARNED 0x67
.equ P_UPNEW 0x70
.equ P_UPLEN 0x71
.equ P_MATHX 0x80
.equ P_MATHY 0x81
.equ P_MATHZ 0x82
.equ P_ATAN2 0x83
.equ P_HYPOT 0x84
.equ P_NORM3 0x85
+35
View File
@@ -0,0 +1,35 @@
; PURSUE -- point the engine at the nearest asteroid and burn.
; Crude: it makes no attempt to match velocity, so it will fly straight past.
;
.equ P_THROTTLE 0x20
.equ P_AZIMUTH 0x21
.equ P_PITCH 0x22
.equ P_SELECT 0x30
.equ P_NEAREST 0x31
.equ P_RELX 0x32
.equ P_RELY 0x33
.equ P_RELZ 0x34
.equ P_MATHX 0x80
.equ P_MATHY 0x81
.equ P_ATAN2 0x83
.equ P_HYPOT 0x84
in r1, P_NEAREST ; id of the nearest asteroid
out P_SELECT, r1 ; track it
ldi r7, 1000
out P_THROTTLE, r7 ; full power
aim: in r1, P_RELX ; where is it?
in r2, P_RELY
in r3, P_RELZ
out P_MATHX, r1
out P_MATHY, r2
in r4, P_ATAN2 ; azimuth = atan2(dy, dx)
out P_AZIMUTH, r4
in r5, P_HYPOT ; horizontal range = hypot(dx, dy)
out P_MATHX, r5
out P_MATHY, r3
in r6, P_ATAN2 ; elevation = atan2(dz, range)
out P_PITCH, r6
yield
jmp aim
+27
View File
@@ -0,0 +1,27 @@
; TELEMETRY -- every tick, write tick number, fuel and position to the
; downlink buffer as five 32-bit words.
;
.equ P_TICK 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_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]
in r1, P_FUEL
stw r1, [r0+T_FUEL]
in r1, P_POSX
stw r1, [r0+T_X]
in r1, P_POSY
stw r1, [r0+T_Y]
in r1, P_POSZ
stw r1, [r0+T_Z]
yield
jmp loop
+48
View File
@@ -0,0 +1,48 @@
package fixed
// Generated with arbitrary-precision arithmetic; atan(2^-i) in Q2.62.
var atanTable = [...]int64{
3622009729038561421,
2138197195906305896,
1129764675555192497,
573486189672913777,
287855953345232184,
144068303048368714,
72051730834756821,
36028064038054492,
18014306884351854,
9007187801521083,
4503598195715549,
2251799634728302,
1125899884473003,
562949950625109,
281474976361130,
140737488311637,
70368744172202,
35184372088149,
17592186044330,
8796093022197,
4398046511102,
2199023255551,
1099511627775,
549755813887,
274877906943,
137438953471,
68719476735,
34359738367,
17179869183,
8589934591,
4294967295,
2147483647,
1073741823,
536870911,
268435455,
134217727,
67108863,
33554431,
16777215,
8388607,
}
// cordicInvK is 1/K (the CORDIC gain compensation) in Q2.62.
const cordicInvK int64 = 2800459870029452953
+264
View File
@@ -0,0 +1,264 @@
// Package fixed implements deterministic Q32.32 fixed-point arithmetic.
//
// All simulation state uses integer math only, so results are bit-exact on
// every platform. Values range over roughly ±2.1e9 with a resolution of
// about 2.3e-10.
package fixed
import (
"math/bits"
)
// F is a signed Q32.32 fixed-point number.
type F int64
const (
Frac = 32
One F = 1 << Frac
Half F = One / 2
Zero F = 0
Max F = 1<<63 - 1
Min F = -1 << 63
// Pi and friends, Q32.32.
Pi F = 13493037704
TwoPi F = 26986075409
HalfPi F = 6746518852
)
// FromInt converts an integer to fixed point.
func FromInt(i int64) F { return F(i) << Frac }
// FromRatio returns n/d.
func FromRatio(n, d int64) F { return FromInt(n).Div(FromInt(d)) }
// Int truncates toward zero.
func (a F) Int() int64 {
if a < 0 {
return -int64(-a >> Frac)
}
return int64(a >> Frac)
}
// Floor returns the integer floor.
func (a F) Floor() int64 { return int64(a >> Frac) }
// Float64 is for display/debugging only; never use it inside the simulation.
func (a F) Float64() float64 { return float64(a) / float64(One) }
func (a F) Add(b F) F { return a + b }
func (a F) Sub(b F) F { return a - b }
func (a F) Neg() F { return -a }
func (a F) Abs() F {
if a < 0 {
return -a
}
return a
}
// Mul returns a*b, rounded toward negative infinity, saturating on overflow.
func (a F) Mul(b F) F {
neg := (a < 0) != (b < 0)
hi, lo := bits.Mul64(uabs(a), uabs(b))
// Shift the 128-bit product right by Frac.
res := hi<<(64-Frac) | lo>>Frac
if hi>>Frac != 0 || res > 1<<63-1 {
if neg {
return Min
}
return Max
}
if neg {
// Floor for negatives keeps rounding consistent; simple truncation
// toward zero is also deterministic, we choose truncation.
return -F(res)
}
return F(res)
}
// Div returns a/b, truncated toward zero, saturating on overflow. Division by
// zero saturates to Max/Min according to the sign of a (0/0 is 0).
func (a F) Div(b F) F {
if b == 0 {
switch {
case a > 0:
return Max
case a < 0:
return Min
}
return 0
}
neg := (a < 0) != (b < 0)
ua, ub := uabs(a), uabs(b)
hi, lo := ua>>(64-Frac), ua<<Frac
if hi >= ub {
if neg {
return Min
}
return Max
}
q, _ := bits.Div64(hi, lo, ub)
if q > 1<<63-1 {
if neg {
return Min
}
return Max
}
if neg {
return -F(q)
}
return F(q)
}
// MulInt multiplies by a plain integer.
func (a F) MulInt(n int64) F { return a * F(n) }
// DivInt divides by a plain integer.
func (a F) DivInt(n int64) F { return a / F(n) }
func uabs(a F) uint64 {
if a < 0 {
return uint64(-a)
}
return uint64(a)
}
func Min2(a, b F) F {
if a < b {
return a
}
return b
}
func Max2(a, b F) F {
if a > b {
return a
}
return b
}
func Clamp(v, lo, hi F) F {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// Sqrt returns the square root of a. Negative input returns 0.
func (a F) Sqrt() F {
if a <= 0 {
return 0
}
// sqrt(a/2^32)*2^32 = sqrt(a*2^32); a*2^32 fits in 96 bits.
hi, lo := uint64(a)>>(64-Frac), uint64(a)<<Frac
return F(isqrt128(hi, lo))
}
// isqrt128 returns floor(sqrt(hi:lo)) using the restoring bit-by-bit method.
func isqrt128(hi, lo uint64) uint64 {
var root uint64
var remHi, remLo uint64
for i := 0; i < 64; i++ {
// Bring down the next two bits of the radicand.
remHi = remHi<<2 | remLo>>62
remLo = remLo<<2 | hi>>62
hi = hi<<2 | lo>>62
lo <<= 2
// trial = (oldRoot<<2)|1 = (newRoot<<1)|1
root <<= 1
trialHi, trialLo := root>>63, root<<1|1
if remHi > trialHi || (remHi == trialHi && remLo >= trialLo) {
var borrow uint64
remLo, borrow = bits.Sub64(remLo, trialLo, 0)
remHi, _ = bits.Sub64(remHi, trialHi, borrow)
root |= 1
}
}
return root
}
// IntSqrt returns floor(sqrt(n)) for n >= 0.
func IntSqrt(n uint64) uint64 { return isqrt128(0, n) }
const cordicIters = len(atanTable)
// SinCos returns sin and cos of the angle a (radians).
func SinCos(a F) (sin, cos F) {
// Reduce to [-pi, pi).
a = a % TwoPi
if a >= Pi {
a -= TwoPi
} else if a < -Pi {
a += TwoPi
}
// Reduce to [-pi/2, pi/2] with a cosine sign flip.
negCos := false
if a > HalfPi {
a = Pi - a
negCos = true
} else if a < -HalfPi {
a = -Pi - a
negCos = true
}
x, y, z := cordicInvK, int64(0), int64(a)<<30
for i := 0; i < cordicIters; i++ {
dx, dy := y>>uint(i), x>>uint(i)
if z >= 0 {
x, y, z = x-dx, y+dy, z-atanTable[i]
} else {
x, y, z = x+dx, y-dy, z+atanTable[i]
}
}
s, c := F(round30(y)), F(round30(x))
if negCos {
c = -c
}
return s, c
}
func round30(v int64) int64 { return (v + 1<<29) >> 30 }
func Sin(a F) F { s, _ := SinCos(a); return s }
func Cos(a F) F { _, c := SinCos(a); return c }
// Atan2 returns the angle of the vector (x, y) in (-pi, pi].
func Atan2(y, x F) F {
if x == 0 && y == 0 {
return 0
}
// Scale so the vector is large but cannot overflow during iteration.
vx, vy := int64(x), int64(y)
for (vx > 1<<60 || vx < -(1<<60)) || (vy > 1<<60 || vy < -(1<<60)) {
vx >>= 1
vy >>= 1
}
var offset int64 // multiples of pi, in Q32
if vx < 0 {
// Rotate by pi so x >= 0.
vx, vy = -vx, -vy
if y >= 0 {
offset = int64(Pi)
} else {
offset = -int64(Pi)
}
}
// Normalize magnitude up to use precision (keep < 2^61).
for (vx < 1<<59) && (vy < 1<<59) && (vy > -(1 << 59)) {
vx <<= 1
vy <<= 1
}
var z int64
for i := 0; i < cordicIters; i++ {
dx, dy := vy>>uint(i), vx>>uint(i)
if vy > 0 {
vx, vy, z = vx+dx, vy-dy, z+atanTable[i]
} else {
vx, vy, z = vx-dx, vy+dy, z-atanTable[i]
}
}
return F(round30(z) + offset)
}
+78
View File
@@ -0,0 +1,78 @@
package fixed
import (
"math"
"math/rand"
"testing"
)
func near(t *testing.T, name string, got F, want, tol float64) {
t.Helper()
if d := math.Abs(got.Float64() - want); d > tol {
t.Errorf("%s: got %v want %v (diff %g)", name, got.Float64(), want, d)
}
}
func TestMulDiv(t *testing.T) {
near(t, "mul", FromInt(3).Mul(FromRatio(1, 2)), 1.5, 1e-9)
near(t, "mulneg", FromInt(-3).Mul(FromRatio(1, 2)), -1.5, 1e-9)
near(t, "div", FromInt(1).Div(FromInt(3)), 1.0/3, 1e-9)
near(t, "divneg", FromInt(-7).Div(FromInt(2)), -3.5, 1e-9)
if FromInt(1<<30).Mul(FromInt(1<<30)) != Max {
t.Error("expected saturation")
}
if FromInt(1).Div(0) != Max || FromInt(-1).Div(0) != Min {
t.Error("div by zero should saturate")
}
}
func TestSqrt(t *testing.T) {
for _, v := range []float64{0.25, 1, 2, 3, 100, 1e6, 2e9} {
f := F(v * float64(One))
near(t, "sqrt", f.Sqrt(), math.Sqrt(v), 1e-8)
}
if IntSqrt(1<<62) != 1<<31 || IntSqrt(99) != 9 {
t.Error("IntSqrt")
}
}
func TestSinCos(t *testing.T) {
r := rand.New(rand.NewSource(1))
for i := 0; i < 2000; i++ {
a := (r.Float64() - 0.5) * 40
f := F(a * float64(One))
s, c := SinCos(f)
af := f.Float64()
near(t, "sin", s, math.Sin(af), 2e-9)
near(t, "cos", c, math.Cos(af), 2e-9)
}
}
func TestAtan2(t *testing.T) {
r := rand.New(rand.NewSource(2))
for i := 0; i < 2000; i++ {
x, y := (r.Float64()-0.5)*1e6, (r.Float64()-0.5)*1e6
got := Atan2(F(y*float64(One)), F(x*float64(One)))
near(t, "atan2", got, math.Atan2(y, x), 2e-8)
}
near(t, "atan2(0,-1)", Atan2(0, -One), math.Pi, 1e-8)
}
func TestVec3(t *testing.T) {
// A 3-4-12 vector has length 13; scale it far past what a naive x*x would allow.
v := Vec{FromInt(3_000_000), FromInt(4_000_000), FromInt(12_000_000)}
near(t, "norm3", v.Len(), 13_000_000, 1e-6)
u := Vec{FromInt(3), FromInt(4), FromInt(12)}.Unit()
near(t, "unit", u.Len(), 1, 1e-8)
d := FromSpherical(FromRatio(1, 2), FromRatio(3, 10))
near(t, "spherical len", d.Len(), 1, 1e-8)
near(t, "spherical z", d.Z, math.Sin(0.3), 1e-8)
near(t, "spherical x", d.X, math.Cos(0.3)*math.Cos(0.5), 1e-8)
r := Vec{One, 0, 0}.RotateX(HalfPi).RotateZ(HalfPi)
near(t, "rot x", r.X, 0, 1e-8)
near(t, "rot y", r.Y, 1, 1e-8)
r = Vec{0, One, 0}.RotateX(HalfPi)
near(t, "rotx z", r.Z, 1, 1e-8)
}
+62
View File
@@ -0,0 +1,62 @@
package fixed
import "math/bits"
// Vec is a 3D fixed-point vector.
type Vec struct{ X, Y, Z F }
func (a Vec) Add(b Vec) Vec { return Vec{a.X + b.X, a.Y + b.Y, a.Z + b.Z} }
func (a Vec) Sub(b Vec) Vec { return Vec{a.X - b.X, a.Y - b.Y, a.Z - b.Z} }
func (a Vec) Scale(k F) Vec { return Vec{a.X.Mul(k), a.Y.Mul(k), a.Z.Mul(k)} }
// Len returns the vector magnitude. The squares are accumulated in 128 bits
// (three squares of 63-bit values cannot overflow that), so it is exact to the
// last bit over the whole F range.
func (a Vec) Len() F { return Norm3(a.X, a.Y, a.Z) }
// Hypot returns sqrt(x*x + y*y) without intermediate overflow.
func Hypot(x, y F) F { return Norm3(x, y, 0) }
// Norm3 returns sqrt(x*x + y*y + z*z) without intermediate overflow.
func Norm3(x, y, z F) F {
h1, l1 := bits.Mul64(uabs(x), uabs(x))
h2, l2 := bits.Mul64(uabs(y), uabs(y))
h3, l3 := bits.Mul64(uabs(z), uabs(z))
lo, c := bits.Add64(l1, l2, 0)
hi, _ := bits.Add64(h1, h2, c)
lo, c = bits.Add64(lo, l3, 0)
hi, _ = bits.Add64(hi, h3, c)
return F(isqrt128(hi, lo))
}
// Unit returns the unit vector, or zero for the zero vector.
func (a Vec) Unit() Vec {
l := a.Len()
if l == 0 {
return Vec{}
}
return Vec{a.X.Div(l), a.Y.Div(l), a.Z.Div(l)}
}
// FromSpherical returns the unit vector with the given azimuth (angle from +X
// in the XY plane) and elevation (angle above the XY plane).
func FromSpherical(azimuth, elevation F) Vec {
sa, ca := SinCos(azimuth)
se, ce := SinCos(elevation)
return Vec{ce.Mul(ca), ce.Mul(sa), se}
}
// RotateZ rotates counter-clockwise about the Z axis.
func (a Vec) RotateZ(ang F) Vec {
s, c := SinCos(ang)
return Vec{a.X.Mul(c) - a.Y.Mul(s), a.X.Mul(s) + a.Y.Mul(c), a.Z}
}
// RotateX rotates counter-clockwise about the X axis.
func (a Vec) RotateX(ang F) Vec {
s, c := SinCos(ang)
return Vec{a.X, a.Y.Mul(c) - a.Z.Mul(s), a.Y.Mul(s) + a.Z.Mul(c)}
}
// Dot returns the dot product, saturating on overflow.
func (a Vec) Dot(b Vec) F { return a.X.Mul(b.X) + a.Y.Mul(b.Y) + a.Z.Mul(b.Z) }
+25
View File
@@ -0,0 +1,25 @@
module wh
go 1.25.0
require (
github.com/jackc/pgx/v5 v5.11.0
modernc.org/sqlite v1.59.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.29.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+74
View File
@@ -0,0 +1,74 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+48
View File
@@ -0,0 +1,48 @@
// Package market prices ore and settles deliveries. Prices are integers
// (credits per kg) that fall as supply accumulates and recover over time.
package market
import "wh/world"
var basePrice = [world.NumOre]int64{world.Iron: 2, world.Nickel: 6, world.Ice: 3, world.Platinum: 300}
// saturation is the supply (kg) at which an ore's price has halved.
var saturation = [world.NumOre]int64{world.Iron: 400_000, world.Nickel: 200_000, world.Ice: 300_000, world.Platinum: 5_000}
type Market struct {
Supply [world.NumOre]int64 // decayed running total of kg sold
Prices [world.NumOre]int64 // credits per kg, fixed for the day
}
func New() Market {
m := Market{}
m.reprice()
return m
}
func (m *Market) reprice() {
for o := range m.Prices {
p := basePrice[o] * saturation[o] / (saturation[o] + m.Supply[o])
if p < 1 {
p = 1
}
m.Prices[o] = p
}
}
// Value returns the payout for a cargo at today's prices.
func (m *Market) Value(cargo [world.NumOre]int64) int64 {
var v int64
for o, kg := range cargo {
v += kg * m.Prices[o]
}
return v
}
// EndOfDay folds the day's sales into supply (with 10% decay) and reprices.
func (m *Market) EndOfDay(sold [world.NumOre]int64) {
for o := range m.Supply {
m.Supply[o] = m.Supply[o]*9/10 + sold[o]
}
m.reprice()
}
+74
View File
@@ -0,0 +1,74 @@
// Package rng provides a small deterministic PRNG (xoshiro256**) seeded via
// splitmix64. Streams are derived from a seed plus labels so that independent
// parts of the simulation never share (and thus never perturb) each other's
// random sequence.
package rng
import (
"math/bits"
"wh/fixed"
)
type R struct{ s [4]uint64 }
func splitmix(x *uint64) uint64 {
*x += 0x9e3779b97f4a7c15
z := *x
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9
z = (z ^ (z >> 27)) * 0x94d049bb133111eb
return z ^ (z >> 31)
}
// New returns a generator for the given seed.
func New(seed uint64) *R {
r := &R{}
for i := range r.s {
r.s[i] = splitmix(&seed)
}
return r
}
// Derive returns a generator for an independent stream identified by seed and
// labels, e.g. Derive(worldSeed, day, shipID).
func Derive(seed uint64, labels ...uint64) *R {
h := seed
for _, l := range labels {
h ^= l + 0x9e3779b97f4a7c15 + (h << 6) + (h >> 2)
splitmix(&h)
}
return New(h)
}
func (r *R) Uint64() uint64 {
s := &r.s
res := bits.RotateLeft64(s[1]*5, 7) * 9
t := s[1] << 17
s[2] ^= s[0]
s[3] ^= s[1]
s[1] ^= s[2]
s[0] ^= s[3]
s[2] ^= t
s[3] = bits.RotateLeft64(s[3], 45)
return res
}
// Intn returns a uniform value in [0, n). n must be > 0.
func (r *R) Intn(n uint64) uint64 {
hi, lo := bits.Mul64(r.Uint64(), n)
if lo < n {
thresh := -n % n
for lo < thresh {
hi, lo = bits.Mul64(r.Uint64(), n)
}
}
return hi
}
// Fixed returns a uniform value in [0, 1).
func (r *R) Fixed() fixed.F { return fixed.F(r.Uint64() >> 32) }
// FixedRange returns a uniform value in [lo, hi).
func (r *R) FixedRange(lo, hi fixed.F) fixed.F {
return lo + r.Fixed().Mul(hi-lo)
}
+80
View File
@@ -0,0 +1,80 @@
// Package runner orchestrates the once-a-day simulation against the store.
package runner
import (
"context"
"encoding/json"
"fmt"
"time"
"wh/config"
"wh/sim"
"wh/store"
)
const (
MetaConfig = "config"
MetaDay = "day"
MetaMarket = "market"
)
// RunNextDay simulates the next day atomically: everything is committed
// together or not at all, so a crashed run can simply be repeated.
func RunNextDay(ctx context.Context, s *store.Store, cfg config.Config) (sim.DayResult, error) {
var res sim.DayResult
cfgJSON, err := json.Marshal(cfg)
if err != nil {
return res, err
}
err = s.WithTx(ctx, func(q *store.Q) error {
if err := q.LockWorld(ctx); err != nil {
return err
}
// Refuse to continue a world with different parameters.
if prev, ok, err := q.GetMeta(ctx, MetaConfig); err != nil {
return err
} else if ok && prev != string(cfgJSON) {
return fmt.Errorf("config differs from the one this world was created with:\n stored: %s\n current: %s", prev, cfgJSON)
}
st, err := q.LoadState(ctx)
if err != nil {
return err
}
if st == nil {
st = sim.NewState(cfg)
if err := q.SetMeta(ctx, MetaConfig, string(cfgJSON)); err != nil {
return err
}
if err := q.SaveState(ctx, st); err != nil { // day 0 snapshot
return err
}
}
var in sim.DayInput
if in.Launches, err = q.PendingLaunches(ctx); err != nil {
return err
}
if in.Uplinks, err = q.PendingUplinks(ctx); err != nil {
return err
}
if res, err = st.RunDay(cfg, in); err != nil {
return err
}
if err := q.SaveState(ctx, st); err != nil {
return err
}
if err := q.RecordRun(ctx, res, time.Now().UTC().Format(time.RFC3339)); err != nil {
return err
}
if err := q.AfterRun(ctx, st); err != nil {
return err
}
mk, _ := json.Marshal(st.Market.Prices)
if err := q.SetMeta(ctx, MetaMarket, string(mk)); err != nil {
return err
}
return q.SetMeta(ctx, MetaDay, fmt.Sprint(st.Day))
})
return res, err
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Run one simulated day inside the running container.
#
# scripts/daily.sh [daily flags...]
#
# Schedule it once a day, e.g. in crontab:
# 0 6 * * * /path/to/scripts/daily.sh >> /var/log/halcyon-daily.log 2>&1
#
# Environment:
# HALCYON_CONTAINER container name (default: halcyon, as in docker-compose.yml)
#
# The world settings (WH_SEED, WH_ASTEROIDS, ...) come from the container's
# environment, so the daily run always matches the server.
set -euo pipefail
container="${HALCYON_CONTAINER:-halcyon}"
if ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -qx true; then
echo "error: container '$container' is not running (try: docker compose up -d)" >&2
exit 1
fi
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) running daily job in $container"
# No -t: this must work from cron, where there is no terminal.
exec docker exec "$container" daily "$@"
+219
View File
@@ -0,0 +1,219 @@
package sim
import (
"wh/config"
"wh/fixed"
"wh/world"
)
// tickCtx is per-tick shared context; asteroid positions are computed lazily
// and cached because several ships may query them in one tick.
type tickCtx struct {
s *State
cfg config.Config
tick int
now int64
stPos fixed.Vec
stVel fixed.Vec
res *DayResult
sold *[world.NumOre]int64
astPos, astVel []fixed.Vec
astDone []bool
}
func newTickCtx(s *State, cfg config.Config, tick int, now int64, stPos, stVel fixed.Vec, res *DayResult, sold *[world.NumOre]int64) *tickCtx {
n := len(s.Asteroids)
return &tickCtx{s: s, cfg: cfg, tick: tick, now: now, stPos: stPos, stVel: stVel, res: res, sold: sold,
astPos: make([]fixed.Vec, n), astVel: make([]fixed.Vec, n), astDone: make([]bool, n)}
}
func (tc *tickCtx) asteroid(i int) (fixed.Vec, fixed.Vec) {
if !tc.astDone[i] {
tc.astPos[i], tc.astVel[i] = tc.s.Asteroids[i].Orbit.State(tc.now)
tc.astDone[i] = true
}
return tc.astPos[i], tc.astVel[i]
}
// shipBus is one ship's view of its peripherals for one tick.
type shipBus struct {
tc *tickCtx
sh *Ship
}
func sat32(v int64) int32 {
if v > 1<<31-1 {
return 1<<31 - 1
}
if v < -1<<31 {
return -1 << 31
}
return int32(v)
}
func kmInt(f fixed.F) int32 { return sat32(f.Floor()) }
// mps converts km/s to whole m/s.
func mps(f fixed.F) int32 { return sat32(f.MulInt(1000).Floor()) }
func (b *shipBus) target() (pos, vel fixed.Vec, ok bool) {
id := b.sh.Target
if id < 1 || id > int64(len(b.tc.s.Asteroids)) {
return pos, vel, false
}
pos, vel = b.tc.asteroid(int(id - 1))
return pos, vel, true
}
// axis returns component (port-base) of v (0=X, 1=Y, 2=Z) using conv, for
// ports laid out as three consecutive X, Y, Z registers.
func axis(v fixed.Vec, port, base uint16, conv func(fixed.F) int32) int32 {
switch port - base {
case 0:
return conv(v.X)
case 1:
return conv(v.Y)
}
return conv(v.Z)
}
func (b *shipBus) In(port uint16) int32 {
sh, tc := b.sh, b.tc
switch {
case port == PortTick:
return int32(tc.tick)
case port == PortDay:
return sat32(tc.s.Day)
case port == PortTicks:
return int32(tc.cfg.TicksPerDay)
case port >= PortPosX && port <= PortPosZ:
return axis(sh.Pos, port, PortPosX, kmInt)
case port >= PortVelX && port <= PortVelZ:
return axis(sh.Vel, port, PortVelX, mps)
case port == PortFuel:
return sat32(sh.Fuel.Floor())
case port == PortMass:
return sat32(sh.Mass().Floor())
case port == PortScanNearest:
best, bestD := int64(0), fixed.Max
for i := range tc.s.Asteroids {
p, _ := tc.asteroid(i)
if d := p.Sub(sh.Pos).Len(); d < bestD {
best, bestD = int64(i+1), d
}
}
return int32(best)
case port >= PortScanRelX && port <= PortScanDist:
p, v, ok := b.target()
if !ok {
return 0
}
rel, relV := p.Sub(sh.Pos), v.Sub(sh.Vel)
switch {
case port <= PortScanRelZ:
return axis(rel, port, PortScanRelX, kmInt)
case port <= PortScanRelVZ:
return axis(relV, port, PortScanRelVX, mps)
}
return kmInt(rel.Len())
case port >= PortScanOre && port < PortScanOre+8:
if a := tc.s.asteroid(sh.Target); a != nil && port-PortScanOre < uint16(world.NumOre) {
return sat32(a.Ore[port-PortScanOre])
}
return 0
case port == PortCargo:
return sat32(sh.CargoTotal())
case port == PortCargoCap:
return sat32(sh.Hull.CargoCap)
case port >= PortCargoOre && port < PortCargoOre+8:
if port-PortCargoOre < uint16(world.NumOre) {
return sat32(sh.Cargo[port-PortCargoOre])
}
return 0
case port >= PortStnRelX && port <= PortStnRelVZ:
rel, relV := tc.stPos.Sub(sh.Pos), tc.stVel.Sub(sh.Vel)
if port <= PortStnRelZ {
return axis(rel, port, PortStnRelX, kmInt)
}
return axis(relV, port, PortStnRelVX, mps)
case port == PortCredits:
return sat32(sh.Earned)
case port == PortUplinkNew:
if sh.UplinkNew {
return 1
}
return 0
case port == PortUplinkLen:
return sh.UplinkLen
case port == PortMathAtan2:
a := fixed.Atan2(fixed.FromInt(int64(sh.MathY)), fixed.FromInt(int64(sh.MathX)))
return sat32(a.MulInt(1000).Floor())
case port == PortMathHypot:
return sat32(fixed.Hypot(fixed.FromInt(int64(sh.MathX)), fixed.FromInt(int64(sh.MathY))).Floor())
case port == PortMathNorm3:
return sat32(fixed.Norm3(fixed.FromInt(int64(sh.MathX)), fixed.FromInt(int64(sh.MathY)), fixed.FromInt(int64(sh.MathZ))).Floor())
}
return 0
}
func (b *shipBus) Out(port uint16, v int32) {
sh, tc := b.sh, b.tc
switch port {
case PortThrottle:
sh.Throttle = v
case PortAzimuth:
sh.Azimuth = v
case PortPitch:
sh.Pitch = v
case PortScanSelect:
sh.Target = int64(v)
case PortMine:
sh.Mining = v != 0
case PortSell:
if v == 0 || !tc.s.canReach(sh, tc.stPos, tc.stVel, DockRangeKm) {
return
}
value := tc.s.Market.Value(sh.Cargo)
if value == 0 {
return
}
tc.s.Credits[sh.Owner] += value
sh.Earned += value
for o, kg := range sh.Cargo {
tc.sold[o] += kg
sh.Cargo[o] = 0
}
tc.res.Events = append(tc.res.Events, Event{tc.tick, sh.ID, "sold", itoa(value) + " credits"})
case PortUplinkNew:
sh.UplinkNew = false
case PortMathX:
sh.MathX = v
case PortMathY:
sh.MathY = v
case PortMathZ:
sh.MathZ = v
}
}
func itoa(v int64) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var b [20]byte
i := len(b)
for v > 0 {
i--
b[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
+56
View File
@@ -0,0 +1,56 @@
package sim
import (
"crypto/sha256"
"encoding/binary"
"sort"
)
// Hash returns a digest of the complete simulation state. Two runs from the
// same seed and inputs must produce identical hashes.
func (s *State) Hash() [32]byte {
h := sha256.New()
w := func(vs ...int64) {
var b [8]byte
for _, v := range vs {
binary.LittleEndian.PutUint64(b[:], uint64(v))
h.Write(b[:])
}
}
w(s.Day, int64(len(s.Asteroids)))
for i := range s.Asteroids {
a := &s.Asteroids[i]
w(a.ID)
for _, o := range a.Ore {
w(o)
}
}
w(int64(len(s.Ships)))
for _, sh := range s.Ships {
alive := int64(0)
if sh.Alive {
alive = 1
}
w(sh.ID, sh.Owner, alive, int64(sh.Pos.X), int64(sh.Pos.Y), int64(sh.Pos.Z),
int64(sh.Vel.X), int64(sh.Vel.Y), int64(sh.Vel.Z),
int64(sh.Fuel), sh.Earned, int64(sh.Throttle), int64(sh.Azimuth), int64(sh.Pitch), sh.Target)
for _, c := range sh.Cargo {
w(c)
}
h.Write(sh.CPU.MarshalState())
}
owners := make([]int64, 0, len(s.Credits))
for o := range s.Credits {
owners = append(owners, o)
}
sort.Slice(owners, func(i, j int) bool { return owners[i] < owners[j] })
for _, o := range owners {
w(o, s.Credits[o])
}
for _, v := range s.Market.Supply {
w(v)
}
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
+67
View File
@@ -0,0 +1,67 @@
package sim
// Peripheral port numbers. Values are int32; positions are kilometres,
// velocities metres/second, angles milliradians, masses kilograms. Axes are
// right-handed with Z perpendicular to the ecliptic; azimuth is measured from
// +X towards +Y and elevation (pitch) up from the XY plane towards +Z.
const (
// System.
PortTick = 0x00 // in: tick within the current day
PortDay = 0x01 // in: day number
PortTicks = 0x02 // in: ticks per day
// Navigation (relative to the star).
PortPosX = 0x10
PortPosY = 0x11
PortPosZ = 0x12
PortVelX = 0x13
PortVelY = 0x14
PortVelZ = 0x15
// Engine.
PortThrottle = 0x20 // out: 0..1000 permille
PortAzimuth = 0x21 // out: thrust direction azimuth, milliradians
PortPitch = 0x22 // out: thrust direction elevation, milliradians
PortFuel = 0x23 // in: fuel kg
PortMass = 0x24 // in: total mass kg
// Scanner.
PortScanSelect = 0x30 // out: asteroid id to track (0 clears)
PortScanNearest = 0x31 // in: id of nearest asteroid
PortScanRelX = 0x32 // in: target position relative to ship, km
PortScanRelY = 0x33
PortScanRelZ = 0x34
PortScanRelVX = 0x35 // in: target velocity relative to ship, m/s
PortScanRelVY = 0x36
PortScanRelVZ = 0x37
PortScanDist = 0x38 // in: distance to target, km
PortScanOre = 0x40 // in: PortScanOre+ore = kg of ore remaining (8 ports)
// Mining laser and cargo hold.
PortMine = 0x50 // out: 1 to mine the selected asteroid, 0 to stop
PortCargo = 0x51 // in: total cargo kg
PortCargoCap = 0x52 // in: cargo capacity kg
PortCargoOre = 0x58 // in: PortCargoOre+ore = kg of ore carried (8 ports)
// Dropoff station.
PortStnRelX = 0x60 // in: station position relative to ship, km
PortStnRelY = 0x61
PortStnRelZ = 0x62
PortStnRelVX = 0x63 // in: station velocity relative to ship, m/s
PortStnRelVY = 0x64
PortStnRelVZ = 0x65
PortSell = 0x66 // out: 1 to sell all cargo (needs to be docked)
PortCredits = 0x67 // in: credits earned by this ship (saturating)
// Comms buffers live in RAM; these ports coordinate them.
PortUplinkNew = 0x70 // in: 1 if an uplink arrived this day; out: any value acknowledges
PortUplinkLen = 0x71 // in: uplink length in bytes
// Math coprocessor. Write operands, then read a result.
PortMathX = 0x80 // out
PortMathY = 0x81 // out
PortMathZ = 0x82 // out
PortMathAtan2 = 0x83 // in: atan2(y, x) in milliradians
PortMathHypot = 0x84 // in: sqrt(x*x + y*y)
PortMathNorm3 = 0x85 // in: sqrt(x*x + y*y + z*z)
)
+211
View File
@@ -0,0 +1,211 @@
package sim
import (
"math"
"testing"
"wh/config"
"wh/fixed"
"wh/vm"
)
func testCfg() config.Config {
c := config.Default()
c.AsteroidCount = 20
return c
}
func mustAsm(t *testing.T, src string) []byte {
t.Helper()
p, err := vm.Assemble(src)
if err != nil {
t.Fatal(err)
}
return p
}
func addShip(t *testing.T, s *State, cfg config.Config, id int64, prog []byte, pos, vel fixed.Vec) *Ship {
t.Helper()
cpu, err := vm.New(prog, cfg.RAMBytes)
if err != nil {
t.Fatal(err)
}
sh := &Ship{ID: id, Owner: 1, Hull: CommandShip, Pos: pos, Vel: vel,
Fuel: fixed.FromInt(CommandShip.FuelCap), CPU: cpu, Alive: true}
s.Ships = append(s.Ships, sh)
return sh
}
const burner = `
ldi r0, 1000
out 0x20, r0 ; full throttle
ldi r0, 500
out 0x21, r0 ; azimuth 0.5 rad
ldi r0, 300
out 0x22, r0 ; pitch 0.3 rad (out of the ecliptic plane)
loop:
yield
jmp loop
`
func TestDeterministic(t *testing.T) {
cfg := testCfg()
run := func() [32]byte {
s := NewState(cfg)
in := DayInput{
Launches: []Launch{{ShipID: 1, Owner: 1, Program: mustAsm(t, burner)}},
Uplinks: []Uplink{{ShipID: 1, Data: []byte("hello")}},
}
var res DayResult
var err error
for d := 0; d < 3; d++ {
res, err = s.RunDay(cfg, in)
if err != nil {
t.Fatal(err)
}
in = DayInput{}
}
return res.Hash
}
if a, b := run(), run(); a != b {
t.Fatal("simulation is not deterministic")
}
}
func TestCircularOrbitHolds(t *testing.T) {
cfg := testCfg()
s := NewState(cfg)
pos, vel := s.Station.Orbit.State(0)
sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), pos, vel)
r0 := pos.Len().Float64()
for d := 0; d < 5; d++ {
if _, err := s.RunDay(cfg, DayInput{}); err != nil {
t.Fatal(err)
}
}
r := sh.Pos.Len().Float64()
if !sh.Alive || r < r0*0.99 || r > r0*1.01 {
t.Fatalf("radius drifted from %.0f to %.0f km", r0, r)
}
}
func TestBurnUsesFuelAndChangesVelocity(t *testing.T) {
cfg := testCfg()
s := NewState(cfg)
pos, vel := s.Station.Orbit.State(0)
sh := addShip(t, s, cfg, 1, mustAsm(t, burner), pos, vel)
if _, err := s.RunDay(cfg, DayInput{}); err != nil {
t.Fatal(err)
}
if sh.Fuel >= fixed.FromInt(CommandShip.FuelCap) || sh.Fuel < 0 {
t.Fatalf("fuel = %v", sh.Fuel.Float64())
}
dv := sh.Vel.Sub(vel)
if dv.Len() < fixed.FromRatio(1, 10) {
t.Fatalf("velocity barely changed: %v", dv.Len().Float64())
}
// Thrust at 0.3 rad pitch must push the ship out of the ecliptic: the
// station's orbit is in-plane and gravity is central, so any Z velocity
// comes from the engine.
if dv.Z <= 0 {
t.Fatalf("expected upward velocity change, got dz=%v", dv.Z.Float64())
}
// The burn direction should match azimuth 0.5 / pitch 0.3 (gravity is
// small next to a ~10 km/s burn).
wantZ := math.Sin(0.3)
if got := dv.Z.Float64() / dv.Len().Float64(); math.Abs(got-wantZ) > 0.05 {
t.Fatalf("burn elevation: sin = %.3f, want %.3f", got, wantZ)
}
wantAz := 0.5
if got := math.Atan2(dv.Y.Float64(), dv.X.Float64()); math.Abs(got-wantAz) > 0.05 {
t.Fatalf("burn azimuth = %.3f, want %.3f", got, wantAz)
}
}
func TestInclinedOrbitLeavesPlane(t *testing.T) {
cfg := testCfg()
s := NewState(cfg)
// Find an asteroid with a noticeable inclination and confirm a ship on its
// orbit stays on a plane that is not the ecliptic.
var best int
for i := range s.Asteroids {
if s.Asteroids[i].Orbit.Inc > s.Asteroids[best].Orbit.Inc {
best = i
}
}
o := s.Asteroids[best].Orbit
if o.Inc < fixed.FromRatio(1, 20) {
t.Skip("no inclined asteroid in this seed")
}
var maxZ fixed.F
for k := int64(0); k < 20; k++ {
p, _ := o.State(k * o.Period / 20)
maxZ = fixed.Max2(maxZ, p.Z.Abs())
}
if maxZ < fixed.FromInt(1000) {
t.Fatalf("inclined orbit never left the plane: max |z| = %v km", maxZ.Float64())
}
}
func TestMineAndSell(t *testing.T) {
cfg := testCfg()
s := NewState(cfg)
// Sit on asteroid 1, matching its motion for the whole first tick, and mine.
pos, vel := s.Asteroids[0].Orbit.State(0)
sh := addShip(t, s, cfg, 1, mustAsm(t, `
ldi r0, 1
out 0x30, r0 ; select asteroid 1
out 0x50, r0 ; mine
yield
jmp -2
`), pos, vel)
if _, err := s.RunDay(cfg, DayInput{}); err != nil {
t.Fatal(err)
}
// Gravity acts on the ship differently than the rails, so it soon drifts
// out of range; it should still have mined something at the start.
if sh.CargoTotal() == 0 {
t.Fatal("nothing mined")
}
// Now dock at the station and sell.
s2 := NewState(cfg)
sp, sv := s2.Station.Orbit.State(0)
sh2 := addShip(t, s2, cfg, 1, mustAsm(t, "ldi r0, 1\n out 0x66, r0\n halt"), sp, sv)
sh2.Cargo[0] = 1000 // iron
if _, err := s2.RunDay(cfg, DayInput{}); err != nil {
t.Fatal(err)
}
if s2.Credits[1] != 2000 || sh2.CargoTotal() != 0 {
t.Fatalf("credits=%d cargo=%d", s2.Credits[1], sh2.CargoTotal())
}
if s2.Market.Prices[0] >= 2 && s2.Market.Supply[0] != 1000 {
t.Fatalf("market not updated: %+v", s2.Market)
}
}
func TestCommsBuffers(t *testing.T) {
cfg := testCfg()
s := NewState(cfg)
pos, vel := s.Station.Orbit.State(0)
// Copy the first uplink word into the TX buffer, then ack.
prog := mustAsm(t, `
.equ TX 1024
in r1, 0x70
ldi r2, 0
beq r1, r2, done
ldw r3, [r2]
stw r3, [r2+TX]
out 0x70, r1
done:
halt
`)
addShip(t, s, cfg, 1, prog, pos, vel)
res, err := s.RunDay(cfg, DayInput{Uplinks: []Uplink{{ShipID: 1, Data: []byte("PING")}}})
if err != nil {
t.Fatal(err)
}
if len(res.Downlinks) != 1 || string(res.Downlinks[0].Data[:4]) != "PING" {
t.Fatalf("downlink = %q", res.Downlinks)
}
}
+150
View File
@@ -0,0 +1,150 @@
// Package sim is the deterministic core: given a State, the day's inputs and
// a Config it advances the world by one day. It performs no I/O.
package sim
import (
"fmt"
"wh/config"
"wh/fixed"
"wh/market"
"wh/vm"
"wh/world"
)
// Hull describes the physical properties of a ship design.
type Hull struct {
DryMass int64 // kg
FuelCap int64 // kg
CargoCap int64 // kg
Thrust int64 // newtons at full throttle
ExhaustVel int64 // m/s
MineRate int64 // kg mined per tick
}
// CommandShip is the only hull for now.
var CommandShip = Hull{
DryMass: 8000,
FuelCap: 4000,
CargoCap: 6000,
Thrust: 6000,
ExhaustVel: 30000,
MineRate: 10,
}
// Docking / mining limits.
const (
MineRangeKm = 5 // max distance to an asteroid for mining
DockRangeKm = 20 // max distance to the station for selling
MaxRelSpeedM = 100 // max relative speed (m/s) for mining or docking
StarRadiusKm = 200_000
)
type Ship struct {
ID int64
Owner int64
Hull Hull
Pos fixed.Vec
Vel fixed.Vec
Fuel fixed.F
Cargo [world.NumOre]int64
CPU *vm.CPU
Alive bool
Earned int64 // credits earned so far
// Peripheral state.
Throttle int32
Azimuth int32
Pitch int32
Target int64
Mining bool
UplinkNew bool
UplinkLen int32
MathX int32
MathY int32
MathZ int32
}
func (s *Ship) CargoTotal() int64 {
var t int64
for _, v := range s.Cargo {
t += v
}
return t
}
func (s *Ship) Mass() fixed.F {
return fixed.FromInt(s.Hull.DryMass+s.CargoTotal()) + s.Fuel
}
// State is everything that persists between daily runs.
type State struct {
Day int64
Asteroids []world.Asteroid // sorted by ID
Station world.Station
Ships []*Ship // sorted by ID
Credits map[int64]int64
Market market.Market
}
// NewState builds the initial world from the config seed.
func NewState(cfg config.Config) *State {
asts, st := world.Generate(cfg)
return &State{
Asteroids: asts,
Station: st,
Credits: map[int64]int64{},
Market: market.New(),
}
}
func (s *State) asteroid(id int64) *world.Asteroid {
// Asteroids are numbered 1..N in slice order.
if id >= 1 && id <= int64(len(s.Asteroids)) {
return &s.Asteroids[id-1]
}
return nil
}
// Launch describes a ship entering the belt at the start of a day.
type Launch struct {
ShipID int64
Owner int64
Program []byte
}
// Uplink is a message delivered to a ship's comm buffer at the start of a day.
type Uplink struct {
ShipID int64
Data []byte
}
// DayInput is everything external the simulation consumes for one day.
type DayInput struct {
Launches []Launch // processed in slice order (must be deterministic)
Uplinks []Uplink
}
// Downlink is the content of a ship's transmit buffer at the end of the day.
type Downlink struct {
ShipID int64
Data []byte
}
type Event struct {
Tick int
ShipID int64
Kind string
Detail string
}
type DayResult struct {
Day int64
Downlinks []Downlink
Events []Event
Hash [32]byte
}
func (e Event) String() string {
return fmt.Sprintf("t%04d ship %d %s %s", e.Tick, e.ShipID, e.Kind, e.Detail)
}
+175
View File
@@ -0,0 +1,175 @@
package sim
import (
"fmt"
"sort"
"wh/config"
"wh/fixed"
"wh/vm"
"wh/world"
)
// RunDay simulates one full day, mutating the state, and returns the result.
func (s *State) RunDay(cfg config.Config, in DayInput) (DayResult, error) {
if err := cfg.Validate(); err != nil {
return DayResult{}, err
}
res := DayResult{Day: s.Day}
dayStart := s.Day * config.SecondsPerDay
dt := int64(cfg.TickSeconds())
// Launches: ships appear at the station, matching its velocity.
stPos, stVel := s.Station.Orbit.State(dayStart)
for _, l := range in.Launches {
if len(l.Program) > cfg.ProgramBytes {
return res, fmt.Errorf("ship %d: program of %d bytes exceeds limit %d", l.ShipID, len(l.Program), cfg.ProgramBytes)
}
cpu, err := vm.New(l.Program, cfg.RAMBytes)
if err != nil {
return res, fmt.Errorf("ship %d: %w", l.ShipID, err)
}
s.Ships = append(s.Ships, &Ship{
ID: l.ShipID, Owner: l.Owner, Hull: CommandShip,
Pos: stPos, Vel: stVel, Fuel: fixed.FromInt(CommandShip.FuelCap),
CPU: cpu, Alive: true,
})
res.Events = append(res.Events, Event{0, l.ShipID, "launch", ""})
}
sort.Slice(s.Ships, func(i, j int) bool { return s.Ships[i].ID < s.Ships[j].ID })
// Uplinks land in the RX buffer at the start of RAM.
for _, u := range in.Uplinks {
sh := s.ship(u.ShipID)
if sh == nil || !sh.Alive {
continue
}
n := len(u.Data)
if n > cfg.CommBytes {
n = cfg.CommBytes
}
copy(sh.CPU.RAM[:cfg.CommBytes], u.Data[:n])
sh.UplinkNew, sh.UplinkLen = true, int32(n)
}
var sold [world.NumOre]int64
for tick := 0; tick < cfg.TicksPerDay; tick++ {
now := dayStart + int64(tick)*dt
stPos, stVel = s.Station.Orbit.State(now)
tc := newTickCtx(s, cfg, tick, now, stPos, stVel, &res, &sold)
for _, sh := range s.Ships {
if !sh.Alive {
continue
}
sh.CPU.Run(&shipBus{tc: tc, sh: sh}, cfg.CyclesPerTick)
s.physics(cfg, sh, dt, tick, &res)
if sh.Alive && sh.Mining {
s.mine(sh, tc.now+dt)
}
}
}
for _, sh := range s.Ships {
if sh.Alive {
res.Downlinks = append(res.Downlinks, Downlink{
ShipID: sh.ID,
Data: append([]byte(nil), sh.CPU.RAM[cfg.CommBytes:2*cfg.CommBytes]...),
})
}
sh.UplinkNew = false
}
s.Market.EndOfDay(sold)
s.Day++
res.Hash = s.Hash()
return res, nil
}
func (s *State) ship(id int64) *Ship {
i := sort.Search(len(s.Ships), func(i int) bool { return s.Ships[i].ID >= id })
if i < len(s.Ships) && s.Ships[i].ID == id {
return s.Ships[i]
}
return nil
}
// physics applies gravity and engine thrust for one tick (semi-implicit Euler).
func (s *State) physics(cfg config.Config, sh *Ship, dt int64, tick int, res *DayResult) {
dtF := fixed.FromInt(dt)
// Engine.
if sh.Throttle > 0 && sh.Fuel > 0 {
th := int64(sh.Throttle)
if th > 1000 {
th = 1000
}
thrust := sh.Hull.Thrust * th / 1000
burn := fixed.FromInt(thrust * dt).Div(fixed.FromInt(sh.Hull.ExhaustVel))
if burn > sh.Fuel {
// Partial burn: scale the impulse by the remaining fuel.
thrust = thrust * int64(sh.Fuel) / int64(burn)
burn = sh.Fuel
}
// dv (km/s) = thrust*dt / mass / 1000.
dv := fixed.FromInt(thrust * dt).Div(sh.Mass()).DivInt(1000)
sh.Vel = sh.Vel.Add(thrustDir(sh).Scale(dv))
sh.Fuel -= burn
}
// The star pulls with acceleration v^2/r, so dv = v*v*dt/r toward it.
r := sh.Pos.Len()
if r < fixed.FromInt(StarRadiusKm) {
sh.Alive = false
res.Events = append(res.Events, Event{tick, sh.ID, "destroyed", "fell into the star"})
return
}
g := cfg.OrbitSpeed.Mul(cfg.OrbitSpeed).Mul(dtF).Div(r)
sh.Vel = sh.Vel.Sub(sh.Pos.Unit().Scale(g))
sh.Pos = sh.Pos.Add(sh.Vel.Scale(dtF))
}
func (s *State) canReach(sh *Ship, pos, vel fixed.Vec, rangeKm int64) bool {
rel := pos.Sub(sh.Pos)
relV := vel.Sub(sh.Vel)
return rel.Len() <= fixed.FromInt(rangeKm) &&
relV.Len() <= fixed.FromRatio(MaxRelSpeedM, 1000)
}
// mine moves ore from the target asteroid into the ship's hold, split
// proportionally to the asteroid's composition. at is the absolute time (s)
// at which range is evaluated.
func (s *State) mine(sh *Ship, at int64) {
a := s.asteroid(sh.Target)
if a == nil {
return
}
total := a.TotalOre()
room := sh.Hull.CargoCap - sh.CargoTotal()
amt := min(sh.Hull.MineRate, room, total)
if amt <= 0 {
return
}
pos, vel := a.Orbit.State(at)
if !s.canReach(sh, pos, vel, MineRangeKm) {
return
}
var taken [world.NumOre]int64
var sum int64
for o := range taken {
taken[o] = amt * a.Ore[o] / total
sum += taken[o]
}
for o := range taken {
extra := min(amt-sum, a.Ore[o]-taken[o])
taken[o] += extra
sum += extra
}
for o, kg := range taken {
a.Ore[o] -= kg
sh.Cargo[o] += kg
}
}
// thrustDir converts the ship's azimuth/pitch (milliradians) to a unit vector.
func thrustDir(sh *Ship) fixed.Vec {
az := fixed.FromRatio(int64(sh.Azimuth), 1000)
el := fixed.FromRatio(int64(sh.Pitch), 1000)
return fixed.FromSpherical(az, el)
}
+284
View File
@@ -0,0 +1,284 @@
package store
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/gob"
"encoding/hex"
"errors"
"fmt"
"wh/sim"
)
// Ship statuses.
const (
StatusInventory = "inventory" // built, can be programmed
StatusLaunching = "launching" // enters the belt on the next run
StatusActive = "active" // in the belt
StatusDestroyed = "destroyed"
)
var (
ErrNotFound = errors.New("not found")
ErrState = errors.New("ship is not in the right state for that")
ErrTaken = errors.New("name already taken")
)
type Player struct {
ID int64
Name string
Credits int64
}
type Ship struct {
ID int64
OwnerID int64
Status string
ProgramSize int
DownlinkDay int64 // -1 if none yet
}
func HashKey(key string) string {
h := sha256.Sum256([]byte(key))
return hex.EncodeToString(h[:])
}
// Register creates a player with one command ship in inventory and returns
// the player's API key (shown once; only its hash is stored).
func (s *Store) Register(ctx context.Context, name string) (Player, string, int64, error) {
var (
p Player
shipID int64
key string
)
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return p, "", 0, err
}
key = hex.EncodeToString(b)
err := s.WithTx(ctx, func(q *Q) error {
var n int
if err := q.row(ctx, `SELECT COUNT(*) FROM players WHERE name = ?`, name).Scan(&n); err != nil {
return err
}
if n > 0 {
return ErrTaken
}
if err := q.row(ctx, `INSERT INTO players (name, key_hash) VALUES (?, ?) RETURNING id`,
name, HashKey(key)).Scan(&p.ID); err != nil {
return err
}
p.Name = name
return q.row(ctx, `INSERT INTO ships (owner_id, status) VALUES (?, ?) RETURNING id`,
p.ID, StatusInventory).Scan(&shipID)
})
return p, key, shipID, err
}
func (q *Q) PlayerByKey(ctx context.Context, key string) (Player, error) {
var p Player
err := q.row(ctx, `SELECT id, name, credits FROM players WHERE key_hash = ?`, HashKey(key)).
Scan(&p.ID, &p.Name, &p.Credits)
if err == sql.ErrNoRows {
return p, ErrNotFound
}
return p, err
}
func (q *Q) PlayerShips(ctx context.Context, owner int64) ([]Ship, error) {
rows, err := q.query(ctx, `SELECT id, owner_id, status, COALESCE(LENGTH(program), 0), downlink_day
FROM ships WHERE owner_id = ? ORDER BY id`, owner)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ship
for rows.Next() {
var sh Ship
if err := rows.Scan(&sh.ID, &sh.OwnerID, &sh.Status, &sh.ProgramSize, &sh.DownlinkDay); err != nil {
return nil, err
}
out = append(out, sh)
}
return out, rows.Err()
}
// ownedStatus returns the ship's status if owner owns it.
func (q *Q) ownedStatus(ctx context.Context, owner, ship int64) (string, error) {
var st string
err := q.row(ctx, `SELECT status FROM ships WHERE id = ? AND owner_id = ?`, ship, owner).Scan(&st)
if err == sql.ErrNoRows {
return "", ErrNotFound
}
return st, err
}
func (q *Q) requireOneRow(res sql.Result, err error) error {
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrState
}
return nil
}
// SetProgram replaces the program of a ship that is still in inventory.
func (q *Q) SetProgram(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 = ? WHERE id = ? AND owner_id = ? AND status = ?`,
prog, ship, owner, StatusInventory)
return q.requireOneRow(res, err)
}
// Launch queues an inventory ship (which must have a program) to enter the
// belt on the next daily run.
func (q *Q) Launch(ctx context.Context, owner, ship int64) error {
if _, err := q.ownedStatus(ctx, owner, ship); err != nil {
return err
}
res, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE id = ? AND owner_id = ? AND status = ?
AND program IS NOT NULL`, StatusLaunching, ship, owner, StatusInventory)
return q.requireOneRow(res, err)
}
// SetUplink stores the message delivered on the next run, replacing any
// message already queued.
func (q *Q) SetUplink(ctx context.Context, owner, ship int64, data []byte) error {
if _, err := q.ownedStatus(ctx, owner, ship); err != nil {
return err
}
res, err := q.exec(ctx, `UPDATE ships SET uplink = ? WHERE id = ? AND owner_id = ? AND status IN (?, ?)`,
data, ship, owner, StatusActive, StatusLaunching)
return q.requireOneRow(res, err)
}
// Downlink returns the latest downlink and the day it was produced.
func (q *Q) Downlink(ctx context.Context, owner, ship int64) ([]byte, int64, error) {
var (
data []byte
day int64
)
err := q.row(ctx, `SELECT downlink, downlink_day FROM ships WHERE id = ? AND owner_id = ?`, ship, owner).
Scan(&data, &day)
if err == sql.ErrNoRows {
return nil, 0, ErrNotFound
}
return data, day, err
}
// PendingLaunches returns ships due to launch, ordered by id.
func (q *Q) PendingLaunches(ctx context.Context) ([]sim.Launch, error) {
rows, err := q.query(ctx, `SELECT id, owner_id, program FROM ships WHERE status = ? ORDER BY id`, StatusLaunching)
if err != nil {
return nil, err
}
defer rows.Close()
var out []sim.Launch
for rows.Next() {
var l sim.Launch
if err := rows.Scan(&l.ShipID, &l.Owner, &l.Program); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
// PendingUplinks returns queued uplinks, ordered by ship id.
func (q *Q) PendingUplinks(ctx context.Context) ([]sim.Uplink, error) {
rows, err := q.query(ctx, `SELECT id, uplink FROM ships WHERE uplink IS NOT NULL ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []sim.Uplink
for rows.Next() {
var u sim.Uplink
if err := rows.Scan(&u.ShipID, &u.Data); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// LoadState returns the most recent world snapshot, or nil if none exists.
func (q *Q) LoadState(ctx context.Context) (*sim.State, error) {
var data []byte
err := q.row(ctx, `SELECT data FROM snapshots ORDER BY day DESC LIMIT 1`).Scan(&data)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
var st sim.State
if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&st); err != nil {
return nil, fmt.Errorf("decode snapshot: %w", err)
}
return &st, nil
}
func (q *Q) SaveState(ctx context.Context, st *sim.State) error {
var buf bytes.Buffer
if err := gob.NewEncoder(&buf).Encode(st); err != nil {
return err
}
h := st.Hash()
_, err := q.exec(ctx, `INSERT INTO snapshots (day, hash, data) VALUES (?, ?, ?)`,
st.Day, hex.EncodeToString(h[:]), buf.Bytes())
return err
}
// RecordRun writes the outputs of a completed day.
func (q *Q) RecordRun(ctx context.Context, res sim.DayResult, ranAt string) error {
h := res.Hash
if _, err := q.exec(ctx, `INSERT INTO day_runs (day, hash, ran_at) VALUES (?, ?, ?)`,
res.Day, hex.EncodeToString(h[:]), ranAt); err != nil {
return err
}
for _, e := range res.Events {
if _, err := q.exec(ctx, `INSERT INTO events (day, tick, ship_id, kind, detail) VALUES (?, ?, ?, ?, ?)`,
res.Day, e.Tick, e.ShipID, e.Kind, e.Detail); err != nil {
return err
}
}
for _, d := range res.Downlinks {
if _, err := q.exec(ctx, `UPDATE ships SET downlink = ?, downlink_day = ? WHERE id = ?`,
d.Data, res.Day, d.ShipID); err != nil {
return err
}
}
return nil
}
// AfterRun clears delivered uplinks, activates launched ships, marks
// destroyed ones and updates player balances.
func (q *Q) AfterRun(ctx context.Context, st *sim.State) error {
if _, err := q.exec(ctx, `UPDATE ships SET uplink = NULL WHERE uplink IS NOT NULL`); err != nil {
return err
}
if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE status = ?`, StatusActive, StatusLaunching); err != nil {
return err
}
for _, sh := range st.Ships {
if !sh.Alive {
if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE id = ?`, StatusDestroyed, sh.ID); err != nil {
return err
}
}
}
for owner, credits := range st.Credits {
if _, err := q.exec(ctx, `UPDATE players SET credits = ? WHERE id = ?`, credits, owner); err != nil {
return err
}
}
return nil
}
+177
View File
@@ -0,0 +1,177 @@
// Package store persists players, ships and world snapshots in SQLite or
// PostgreSQL through database/sql.
package store
import (
"context"
"database/sql"
"fmt"
"strings"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
type Dialect int
const (
SQLite Dialect = iota
Postgres
)
// Store is a database handle. Methods on the embedded Q run directly on the
// pool; use WithTx for atomic multi-step work.
type Store struct {
*Q
db *sql.DB
}
type execer interface {
ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row
}
// Q holds the query methods; it wraps either a pool or a transaction.
type Q struct {
x execer
d Dialect
tx bool
}
// Open connects using dsn. A "postgres://" or "postgresql://" URL selects
// PostgreSQL; anything else is a SQLite file path (optionally prefixed with
// "sqlite:").
func Open(dsn string) (*Store, error) {
var (
driver string
d Dialect
)
switch {
case strings.HasPrefix(dsn, "postgres://"), strings.HasPrefix(dsn, "postgresql://"):
driver, d = "pgx", Postgres
default:
path := strings.TrimPrefix(dsn, "sqlite:")
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
dsn = "file:" + path + sep + "_txlock=immediate&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
driver, d = "sqlite", SQLite
}
db, err := sql.Open(driver, dsn)
if err != nil {
return nil, err
}
if d == SQLite {
db.SetMaxOpenConns(1) // SQLite allows a single writer; keep it simple
}
return &Store{Q: &Q{x: db, d: d}, db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
// WithTx runs fn in a transaction, committing if it returns nil.
func (s *Store) WithTx(ctx context.Context, fn func(q *Q) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := fn(&Q{x: tx, d: s.d, tx: true}); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
// rebind converts ? placeholders to $n for PostgreSQL.
func (q *Q) rebind(query string) string {
if q.d != Postgres {
return query
}
var b strings.Builder
n := 0
for _, r := range query {
if r == '?' {
n++
fmt.Fprintf(&b, "$%d", n)
} else {
b.WriteRune(r)
}
}
return b.String()
}
func (q *Q) exec(ctx context.Context, query string, args ...any) (sql.Result, error) {
return q.x.ExecContext(ctx, q.rebind(query), args...)
}
func (q *Q) query(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return q.x.QueryContext(ctx, q.rebind(query), args...)
}
func (q *Q) row(ctx context.Context, query string, args ...any) *sql.Row {
return q.x.QueryRowContext(ctx, q.rebind(query), args...)
}
var schema = []string{
`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS players (
id {ID}, name TEXT NOT NULL UNIQUE, key_hash TEXT NOT NULL UNIQUE,
credits BIGINT NOT NULL DEFAULT 0)`,
`CREATE TABLE IF NOT EXISTS ships (
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 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 (
day BIGINT PRIMARY KEY, hash TEXT NOT NULL, data {BLOB} NOT NULL)`,
`CREATE TABLE IF NOT EXISTS events (
id {ID}, day BIGINT NOT NULL, tick INTEGER NOT NULL,
ship_id BIGINT NOT NULL, kind TEXT NOT NULL, detail TEXT NOT NULL)`,
`CREATE INDEX IF NOT EXISTS events_day ON events(day)`,
`CREATE TABLE IF NOT EXISTS day_runs (
day BIGINT PRIMARY KEY, hash TEXT NOT NULL, ran_at TEXT NOT NULL)`,
`INSERT INTO meta (key, value) VALUES ('lock', '') ON CONFLICT (key) DO NOTHING`,
}
// Migrate creates the schema if it does not exist.
func (s *Store) Migrate(ctx context.Context) error {
id, blob := "INTEGER PRIMARY KEY AUTOINCREMENT", "BLOB"
if s.d == Postgres {
id, blob = "BIGSERIAL PRIMARY KEY", "BYTEA"
}
r := strings.NewReplacer("{ID}", id, "{BLOB}", blob)
for _, stmt := range schema {
if _, err := s.exec(ctx, r.Replace(stmt)); err != nil {
return fmt.Errorf("migrate: %w", err)
}
}
return nil
}
// LockWorld serialises daily runs. SQLite transactions are already exclusive
// (immediate mode); PostgreSQL takes a row lock held until commit.
func (q *Q) LockWorld(ctx context.Context) error {
if q.d != Postgres {
return nil
}
var v string
return q.row(ctx, `SELECT value FROM meta WHERE key = 'lock' FOR UPDATE`).Scan(&v)
}
func (q *Q) GetMeta(ctx context.Context, key string) (string, bool, error) {
var v string
err := q.row(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
if err == sql.ErrNoRows {
return "", false, nil
}
return v, err == nil, err
}
func (q *Q) SetMeta(ctx context.Context, key, value string) error {
_, err := q.exec(ctx, `INSERT INTO meta (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
+305
View File
@@ -0,0 +1,305 @@
package vm
import (
"encoding/binary"
"fmt"
"strconv"
"strings"
)
var mnemonics = map[string]Op{
"nop": NOP, "yield": YIELD, "halt": HALT, "ldi": LDI, "lui": LUI, "mov": MOV,
"add": ADD, "sub": SUB, "mul": MUL, "div": DIV, "mod": MOD, "and": AND,
"or": OR, "xor": XOR, "shl": SHL, "shr": SHR, "sar": SAR, "addi": ADDI,
"jmp": JMP, "beq": BEQ, "bne": BNE, "blt": BLT, "bge": BGE, "call": CALL,
"ret": RET, "push": PUSH, "pop": POP, "ldb": LDB, "ldh": LDH, "ldw": LDW,
"stb": STB, "sth": STH, "stw": STW, "in": IN, "out": OUT,
}
// Assemble converts assembly text into a program. Syntax:
//
// label: define a label
// .equ NAME value define a constant
// li rd, value pseudo-op: load a 32-bit constant (always 2 instructions)
// ldw rd, [rb+off] memory access (also ldb/ldh/stb/sth/stw)
// in rd, port | out port, rs
// beq ra, rb, label branches and jmp/call take labels
//
// Registers are r0..r15 (sp = r15). Comments start with ';' or '#'.
func Assemble(src string) ([]byte, error) {
type line struct {
no int
text string
}
var lines []line
consts := map[string]int64{}
labels := map[string]int{}
n := 0 // instruction count
for i, raw := range strings.Split(src, "\n") {
t := raw
if j := strings.IndexAny(t, ";#"); j >= 0 {
t = t[:j]
}
t = strings.TrimSpace(t)
for {
j := strings.Index(t, ":")
if j < 0 || strings.ContainsAny(t[:j], " \t,[") {
break
}
labels[t[:j]] = n
t = strings.TrimSpace(t[j+1:])
}
if t == "" {
continue
}
f := strings.Fields(t)
f[0] = strings.ToLower(f[0])
if f[0] == ".equ" {
if len(f) != 3 {
return nil, fmt.Errorf("line %d: .equ NAME value", i+1)
}
v, err := parseNum(f[2], consts)
if err != nil {
return nil, fmt.Errorf("line %d: %v", i+1, err)
}
consts[f[1]] = v
continue
}
if f[0] == "li" {
n += 2
} else {
n++
}
lines = append(lines, line{i + 1, t})
}
var out []byte
emit := func(op Op, ra, rb int, imm int32) {
out = binary.LittleEndian.AppendUint32(out, Encode(op, ra, rb, imm))
}
for _, l := range lines {
name, rest, _ := strings.Cut(l.text, " ")
name = strings.ToLower(name)
args := splitArgs(rest)
err := func() error {
pc := len(out) / 4
if name == "li" {
if len(args) != 2 {
return fmt.Errorf("li rd, value")
}
rd, err := parseReg(args[0])
if err != nil {
return err
}
v, err := parseNum(args[1], consts)
if err != nil {
return err
}
emit(LDI, rd, 0, int32(int16(uint32(v))))
emit(LUI, rd, 0, int32(int16(uint32(v)>>16)))
return nil
}
op, ok := mnemonics[name]
if !ok {
return fmt.Errorf("unknown mnemonic %q", name)
}
target := func(s string) (int32, error) {
if a, ok := labels[s]; ok {
return int32(a - (pc + 1)), nil
}
v, err := parseNum(s, consts)
return int32(v), err
}
need := func(k int) error {
if len(args) != k {
return fmt.Errorf("%s takes %d operands", name, k)
}
return nil
}
switch op {
case NOP, YIELD, HALT, RET:
if err := need(0); err != nil {
return err
}
emit(op, 0, 0, 0)
case LDI, LUI, ADDI:
if err := need(2); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
v, err := parseNum(args[1], consts)
if err != nil {
return err
}
if v < -32768 || v > 65535 {
return fmt.Errorf("immediate %d out of 16-bit range (use li)", v)
}
emit(op, ra, 0, int32(int16(v)))
case MOV, ADD, SUB, MUL, DIV, MOD, AND, OR, XOR, SHL, SHR, SAR:
if err := need(2); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
rb, err := parseReg(args[1])
if err != nil {
return err
}
emit(op, ra, rb, 0)
case JMP, CALL:
if err := need(1); err != nil {
return err
}
t, err := target(args[0])
if err != nil {
return err
}
emit(op, 0, 0, t)
case BEQ, BNE, BLT, BGE:
if err := need(3); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
rb, err := parseReg(args[1])
if err != nil {
return err
}
t, err := target(args[2])
if err != nil {
return err
}
emit(op, ra, rb, t)
case PUSH, POP:
if err := need(1); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
emit(op, ra, 0, 0)
case LDB, LDH, LDW, STB, STH, STW:
if err := need(2); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
rb, off, err := parseMem(args[1], consts)
if err != nil {
return err
}
emit(op, ra, rb, off)
case IN:
if err := need(2); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
p, err := parseNum(args[1], consts)
if err != nil {
return err
}
emit(op, ra, 0, int32(int16(p)))
case OUT:
if err := need(2); err != nil {
return err
}
p, err := parseNum(args[0], consts)
if err != nil {
return err
}
ra, err := parseReg(args[1])
if err != nil {
return err
}
emit(op, ra, 0, int32(int16(p)))
}
return nil
}()
if err != nil {
return nil, fmt.Errorf("line %d: %v", l.no, err)
}
}
return out, nil
}
func splitArgs(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
func parseReg(s string) (int, error) {
s = strings.ToLower(s)
if s == "sp" {
return SP, nil
}
if strings.HasPrefix(s, "r") {
if n, err := strconv.Atoi(s[1:]); err == nil && n >= 0 && n < NumRegs {
return n, nil
}
}
return 0, fmt.Errorf("bad register %q", s)
}
func parseNum(s string, consts map[string]int64) (int64, error) {
if v, ok := consts[s]; ok {
return v, nil
}
v, err := strconv.ParseInt(s, 0, 64)
if err != nil {
return 0, fmt.Errorf("bad number or unknown name %q", s)
}
return v, nil
}
// parseMem parses "[rb]" or "[rb+off]" / "[rb-off]".
func parseMem(s string, consts map[string]int64) (int, int32, error) {
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
return 0, 0, fmt.Errorf("bad memory operand %q", s)
}
s = strings.TrimSpace(s[1 : len(s)-1])
regPart, offPart := s, ""
if i := strings.IndexAny(s, "+-"); i >= 0 {
regPart, offPart = strings.TrimSpace(s[:i]), strings.TrimSpace(s[i:])
offPart = strings.ReplaceAll(offPart, " ", "")
}
rb, err := parseReg(regPart)
if err != nil {
return 0, 0, err
}
var off int64
if offPart != "" {
sign := int64(1)
if offPart[0] == '-' {
sign = -1
}
v, err := parseNum(offPart[1:], consts)
if err != nil {
return 0, 0, err
}
off = sign * v
}
if off < -32768 || off > 32767 {
return 0, 0, fmt.Errorf("offset %d out of range", off)
}
return rb, int32(off), nil
}
+325
View File
@@ -0,0 +1,325 @@
// Package vm implements the bytecode CPU that runs ship programs.
//
// The machine has 16 32-bit registers (r15 is the stack pointer), a program
// ROM and a byte-addressable little-endian data RAM. Peripherals are reached
// through IN/OUT port instructions. Every instruction is 4 bytes:
//
// byte 0: opcode | byte 1: ra<<4 | rb | bytes 2-3: signed 16-bit immediate
//
// Branch/jump immediates are offsets in instructions relative to the next
// instruction. Each tick a CPU runs up to a cycle budget or until it executes
// YIELD; state persists between ticks so an over-long computation simply
// continues on the next tick.
package vm
import (
"encoding/binary"
"fmt"
)
type Op uint8
const (
NOP Op = iota
YIELD
HALT
LDI
LUI
MOV
ADD
SUB
MUL
DIV
MOD
AND
OR
XOR
SHL
SHR
SAR
ADDI
JMP
BEQ
BNE
BLT
BGE
CALL
RET
PUSH
POP
LDB
LDH
LDW
STB
STH
STW
IN
OUT
numOps
)
const (
NumRegs = 16
SP = 15
)
type Status uint8
const (
Running Status = iota
Yielded // finished this tick's work voluntarily
Halted
Faulted
)
func (s Status) String() string {
return [...]string{"running", "yielded", "halted", "faulted"}[s]
}
// Bus connects the CPU to peripherals.
type Bus interface {
In(port uint16) int32
Out(port uint16, v int32)
}
type CPU struct {
R [NumRegs]int32
PC uint32 // byte address into Prog
Prog []byte
RAM []byte
Status Status
Fault string
}
// New creates a CPU with the program loaded and the stack pointer at the top
// of RAM.
func New(prog []byte, ramBytes int) (*CPU, error) {
if len(prog)%4 != 0 {
return nil, fmt.Errorf("program length %d is not a multiple of 4", len(prog))
}
c := &CPU{Prog: append([]byte(nil), prog...), RAM: make([]byte, ramBytes)}
c.R[SP] = int32(ramBytes)
return c, nil
}
func (c *CPU) fault(format string, args ...any) {
c.Status = Faulted
c.Fault = fmt.Sprintf(format, args...)
}
func cost(op Op) int {
switch op {
case MUL:
return 2
case DIV, MOD:
return 8
}
return 1
}
// Run executes instructions until the budget is spent, YIELD, HALT or a
// fault, returning the cycles used. A yielded CPU resumes on the next call.
func (c *CPU) Run(bus Bus, budget int) int {
if c.Status == Halted || c.Status == Faulted {
return 0
}
c.Status = Running
used := 0
for used < budget {
if int(c.PC)+4 > len(c.Prog) {
// Falling off the end of the program halts the CPU.
c.Status = Halted
break
}
w := binary.LittleEndian.Uint32(c.Prog[c.PC:])
op := Op(w)
ra, rb := (w>>12)&0xf, (w>>8)&0xf
imm := int32(int16(w >> 16))
if op >= numOps {
c.fault("illegal opcode %d at pc=%d", op, c.PC)
break
}
used += cost(op)
next := c.PC + 4
r := &c.R
switch op {
case NOP:
case YIELD:
c.Status = Yielded
case HALT:
c.Status = Halted
case LDI:
r[ra] = imm
case LUI:
r[ra] = int32(uint32(imm)<<16 | uint32(r[ra])&0xffff)
case MOV:
r[ra] = r[rb]
case ADD:
r[ra] += r[rb]
case SUB:
r[ra] -= r[rb]
case MUL:
r[ra] *= r[rb]
case DIV, MOD:
d := r[rb]
if d == 0 {
c.fault("division by zero at pc=%d", c.PC)
break
}
switch {
case d == -1: // avoid MinInt32 / -1 overflow panic semantics
if op == DIV {
r[ra] = -r[ra]
} else {
r[ra] = 0
}
case op == DIV:
r[ra] /= d
default:
r[ra] %= d
}
case AND:
r[ra] &= r[rb]
case OR:
r[ra] |= r[rb]
case XOR:
r[ra] ^= r[rb]
case SHL:
r[ra] = int32(uint32(r[ra]) << (uint32(r[rb]) & 31))
case SHR:
r[ra] = int32(uint32(r[ra]) >> (uint32(r[rb]) & 31))
case SAR:
r[ra] >>= uint32(r[rb]) & 31
case ADDI:
r[ra] += imm
case JMP:
next = uint32(int64(next) + int64(imm)*4)
case BEQ, BNE, BLT, BGE:
var t bool
switch op {
case BEQ:
t = r[ra] == r[rb]
case BNE:
t = r[ra] != r[rb]
case BLT:
t = r[ra] < r[rb]
case BGE:
t = r[ra] >= r[rb]
}
if t {
next = uint32(int64(next) + int64(imm)*4)
}
case CALL:
if c.push(int32(next)) {
next = uint32(int64(next) + int64(imm)*4)
}
case RET:
if v, ok := c.pop(); ok {
next = uint32(v)
}
case PUSH:
c.push(r[ra])
case POP:
if v, ok := c.pop(); ok {
r[ra] = v
}
case LDB, LDH, LDW:
n := accessSize(op)
if a, ok := c.addr(r[rb]+imm, n); ok {
var v uint32
for i := n - 1; i >= 0; i-- {
v = v<<8 | uint32(c.RAM[a+i])
}
r[ra] = int32(v)
}
case STB, STH, STW:
n := accessSize(op)
if a, ok := c.addr(r[rb]+imm, n); ok {
v := uint32(r[ra])
for i := 0; i < n; i++ {
c.RAM[a+i] = byte(v >> (8 * i))
}
}
case IN:
r[ra] = bus.In(uint16(imm))
case OUT:
bus.Out(uint16(imm), r[ra])
}
if c.Status == Faulted {
break
}
c.PC = next
if c.Status != Running {
break
}
}
return used
}
func accessSize(op Op) int {
switch op {
case LDB, STB:
return 1
case LDH, STH:
return 2
}
return 4
}
func (c *CPU) addr(a int32, n int) (int, bool) {
if a < 0 || int(a)+n > len(c.RAM) {
c.fault("memory access out of range: %d", a)
return 0, false
}
return int(a), true
}
func (c *CPU) push(v int32) bool {
a, ok := c.addr(c.R[SP]-4, 4)
if !ok {
return false
}
c.R[SP] -= 4
binary.LittleEndian.PutUint32(c.RAM[a:], uint32(v))
return true
}
func (c *CPU) pop() (int32, bool) {
a, ok := c.addr(c.R[SP], 4)
if !ok {
return 0, false
}
c.R[SP] += 4
return int32(binary.LittleEndian.Uint32(c.RAM[a:])), true
}
// Encode builds one instruction word.
func Encode(op Op, ra, rb int, imm int32) uint32 {
return uint32(op) | uint32(rb&0xf)<<8 | uint32(ra&0xf)<<12 | uint32(uint16(imm))<<16
}
// MarshalState serialises the mutable CPU state (not the program).
func (c *CPU) MarshalState() []byte {
b := make([]byte, 0, NumRegs*4+8+len(c.RAM))
for _, r := range c.R {
b = binary.LittleEndian.AppendUint32(b, uint32(r))
}
b = binary.LittleEndian.AppendUint32(b, c.PC)
b = append(b, byte(c.Status), 0, 0, 0)
return append(b, c.RAM...)
}
// UnmarshalState restores state produced by MarshalState.
func (c *CPU) UnmarshalState(b []byte) error {
hdr := NumRegs*4 + 8
if len(b) != hdr+len(c.RAM) {
return fmt.Errorf("state size %d does not match expected %d", len(b), hdr+len(c.RAM))
}
for i := range c.R {
c.R[i] = int32(binary.LittleEndian.Uint32(b[i*4:]))
}
c.PC = binary.LittleEndian.Uint32(b[NumRegs*4:])
c.Status = Status(b[NumRegs*4+4])
copy(c.RAM, b[hdr:])
return nil
}
+114
View File
@@ -0,0 +1,114 @@
package vm
import "testing"
type testBus struct {
in map[uint16]int32
out map[uint16]int32
}
func (b *testBus) In(p uint16) int32 { return b.in[p] }
func (b *testBus) Out(p uint16, v int32) { b.out[p] = v }
func run(t *testing.T, src string, budget int) (*CPU, *testBus) {
t.Helper()
prog, err := Assemble(src)
if err != nil {
t.Fatal(err)
}
c, err := New(prog, 256)
if err != nil {
t.Fatal(err)
}
b := &testBus{in: map[uint16]int32{5: 42}, out: map[uint16]int32{}}
c.Run(b, budget)
return c, b
}
func TestSumLoop(t *testing.T) {
c, b := run(t, `
ldi r0, 0 ; sum
ldi r1, 1 ; i
ldi r2, 11
loop:
add r0, r1
addi r1, 1
blt r1, r2, loop
out 7, r0
halt
`, 1000)
if c.Status != Halted || b.out[7] != 55 {
t.Fatalf("status=%v out=%d", c.Status, b.out[7])
}
}
func TestLiCallStackMemory(t *testing.T) {
c, b := run(t, `
li r0, 0x12345678
call double
stw r0, [sp-8] ; below current sp
ldw r3, [sp-8]
in r4, 5
add r3, r4
out 1, r3
halt
double:
add r0, r0
ret
`, 1000)
want := int32(0x12345678)*2 + 42
if c.Status != Halted || b.out[1] != want {
t.Fatalf("status=%v fault=%q out=%x want %x", c.Status, c.Fault, b.out[1], want)
}
}
func TestYieldAndBudget(t *testing.T) {
prog, _ := Assemble("l: addi r0, 1\n jmp l")
c, _ := New(prog, 64)
b := &testBus{}
if used := c.Run(b, 100); used != 100 || c.Status != Running {
t.Fatalf("used=%d status=%v", used, c.Status)
}
r0 := c.R[0]
c.Run(b, 100)
if c.R[0] != r0+50 {
t.Fatalf("did not resume: %d -> %d", r0, c.R[0])
}
}
func TestFaults(t *testing.T) {
c, _ := run(t, "ldi r0, 1\n ldi r1, 0\n div r0, r1", 100)
if c.Status != Faulted {
t.Fatal("expected div fault")
}
c, _ = run(t, "ldi r1, 300\n ldw r0, [r1]", 100)
if c.Status != Faulted {
t.Fatal("expected memory fault")
}
}
func TestStateRoundTrip(t *testing.T) {
c, _ := run(t, "ldi r0, 9\n stb r0, [r1+3]\n yield\n ldi r0, 1", 100)
d, _ := New(c.Prog, 256)
if err := d.UnmarshalState(c.MarshalState()); err != nil {
t.Fatal(err)
}
if d.R != c.R || d.PC != c.PC || d.RAM[3] != 9 || d.Status != Yielded {
t.Fatal("state mismatch")
}
}
func TestAssemblerCaseInsensitiveDirectives(t *testing.T) {
// LI expands to two instructions; label distances must agree in any case.
lower, err := Assemble(".equ K 5\n li r1, 0x12345\n jmp end\n nop\nend: halt")
if err != nil {
t.Fatal(err)
}
upper, err := Assemble(".EQU K 5\n LI r1, 0x12345\n JMP end\n NOP\nend: HALT")
if err != nil {
t.Fatal(err)
}
if string(lower) != string(upper) {
t.Fatal("upper- and lower-case source assembled differently")
}
}
+93
View File
@@ -0,0 +1,93 @@
// Client for the belt's HTTP API.
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
const KEY = 'wh.key';
export const keyStore = {
get() {
try { return localStorage.getItem(KEY); } catch { return null; }
},
set(k) {
try { localStorage.setItem(KEY, k); } catch { /* private mode: session only */ }
memoryKey = k;
},
clear() {
try { localStorage.removeItem(KEY); } catch { /* ignore */ }
memoryKey = null;
},
};
let memoryKey = null;
const currentKey = () => memoryKey ?? keyStore.get();
let unauthorized = () => {};
export function onUnauthorized(cb) { unauthorized = cb; }
async function call(method, path, { json, body, type, auth = true, raw = false, key } = {}) {
const headers = {};
// An explicit key (used by login) is tried as given and never triggers the
// global "session rejected" handler.
const k = key ?? (auth ? currentKey() : null);
if (k) headers.Authorization = `Bearer ${k}`;
let payload = body;
if (json !== undefined) {
payload = JSON.stringify(json);
headers['Content-Type'] = 'application/json';
} else if (type) {
headers['Content-Type'] = type;
}
let res;
try {
res = await fetch(path, { method, headers, body: payload });
} catch {
throw new ApiError(0, 'LINK DOWN: cannot reach the server');
}
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
try { msg = (await res.json()).error ?? msg; } catch { /* not JSON */ }
if (res.status === 401 && auth && key === undefined) unauthorized();
throw new ApiError(res.status, msg);
}
if (raw) return res;
if ((res.headers.get('Content-Type') ?? '').includes('json')) return res.json();
return null;
}
export const api = {
info: () => call('GET', '/info', { auth: false }),
market: () => call('GET', '/market', { auth: false }),
register: (name) => call('POST', '/register', { json: { name }, auth: false }),
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`),
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 });
return {
bytes: new Uint8Array(await res.arrayBuffer()),
day: Number(res.headers.get('X-Downlink-Day')),
};
},
async example(name) {
const res = await fetch(`/examples/${name}.s`);
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) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
+166
View File
@@ -0,0 +1,166 @@
// Halcyon Flight Control: application shell and routing.
import { api, keyStore, onUnauthorized } from './api.js';
import { h, clear, toast } from './dom.js';
import { startStarfield } from './starfield.js';
import { statusOf } from './status.js';
import { authView } from './view-auth.js';
import { fleetView } from './view-fleet.js';
import { codeView } from './view-code.js';
import { uplinkView } from './view-uplink.js';
import { downlinkView } from './view-downlink.js';
const root = document.getElementById('app');
const TABS = [
['fleet', 'FLEET'],
['code', 'CODE'],
['uplink', 'UPLINK'],
['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' };
let shell = null; // { top, nav, select, main }
const tabFromHash = () => {
const t = location.hash.replace(/^#\/?/, '');
return VIEWS[t] ? t : 'fleet';
};
const ctx = {
state,
get ship() { return state.ships.find((s) => s.id === state.selected) ?? null; },
go(tab) {
if (location.hash === `#/${tab}`) { state.tab = tab; renderMain(); } else location.hash = `#/${tab}`;
},
selectShip(id, tab) {
state.selected = id;
try { localStorage.setItem('wh.ship', String(id)); } catch { /* ignore */ }
renderNav();
if (tab) ctx.go(tab); else renderMain();
},
async refresh() {
await loadAccount();
renderTop();
renderNav();
renderMain();
},
};
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'));
if (!ships.some((s) => s.id === state.selected)) {
state.selected = ships.some((s) => s.id === remembered) ? remembered : (ships[0]?.id ?? null);
}
}
function safeGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
function logout(message) {
keyStore.clear();
Object.assign(state, { me: null, ships: [], selected: null });
shell = null;
if (message) toast(message, 'err');
showAuth();
}
function showAuth() {
clear(root).append(authView({ onLogin: enter }));
}
async function enter() {
try {
await loadAccount();
} catch (e) {
if (e.status !== 401) toast(`ERROR: ${e.message}`, 'err');
return logout();
}
buildShell();
state.tab = tabFromHash();
renderTop();
renderNav();
renderMain();
}
// ---- shell ------------------------------------------------------------------
function buildShell() {
shell = {
top: h('header', { class: 'topbar' }),
nav: h('nav', { class: 'navbar', 'aria-label': 'Sections' }),
main: h('main', { id: 'main', class: 'main', tabindex: '-1' }),
};
clear(root).append(
h('a', { class: 'skip', href: '#main' }, 'SKIP TO CONTENT'),
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'),
);
}
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')];
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() ?? ''),
h('button', { class: 'btn small', type: 'button', onclick: async () => { try { await ctx.refresh(); toast('DATA REFRESHED'); } catch (e) { toast(e.message, 'err'); } } }, 'REFRESH'),
h('button', { class: 'btn small', type: 'button', onclick: () => logout() }, 'LOGOUT'),
),
);
}
function renderNav() {
if (!shell) return;
const select = h('select', {
id: 'ship-select', 'aria-label': 'Active ship', disabled: state.ships.length === 0,
onchange: (e) => ctx.selectShip(Number(e.target.value)),
}, state.ships.length
? state.ships.map((s) => h('option', { value: s.id, selected: s.id === state.selected }, `#${s.id} ${statusOf(s.status).label}`))
: [h('option', null, 'NO SHIPS')]);
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('label', { class: 'shipsel' }, 'ACTIVE SHIP', select),
);
}
function renderMain() {
if (!shell) return;
clear(shell.main).append(VIEWS[state.tab](ctx));
}
window.addEventListener('hashchange', () => {
if (!shell) return;
state.tab = tabFromHash();
renderNav();
renderMain();
shell.main.focus({ preventScroll: true });
});
// A refresh when the tab regains focus, since the daily run happens while the page sits open.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && shell && state.tab === 'fleet') ctx.refresh().catch(() => {});
});
// ---- boot -------------------------------------------------------------------
startStarfield(document.getElementById('stars'));
onUnauthorized(() => logout('SESSION REJECTED: ACCESS KEY NOT RECOGNISED'));
api.info().then((i) => { state.info = i; }).catch(() => {});
if (keyStore.get()) enter(); else showAuth();
+70
View File
@@ -0,0 +1,70 @@
// Tiny DOM helpers: element builder, toasts and a confirm dialog.
/** Build an element. Props: class, on<Event> handlers, booleans, attributes. */
export function h(tag, props, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props ?? {})) {
if (v == null || v === false) continue;
if (k === 'class') el.className = v;
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
else if (v === true) el.setAttribute(k, '');
else el.setAttribute(k, v);
}
el.append(...kids.flat(Infinity).filter((c) => c != null && c !== false));
return el;
}
export function clear(el) {
el.replaceChildren();
return el;
}
export function toast(message, kind = 'info') {
const host = document.getElementById('toasts');
const t = h('div', { class: `toast ${kind}`, role: kind === 'err' ? 'alert' : null }, message);
host.append(t);
setTimeout(() => {
t.classList.add('out');
setTimeout(() => t.remove(), 400);
}, kind === 'err' ? 8000 : 4500);
}
/** Resolve true if the user confirms. */
export function confirmDialog({ title, body, confirmText = 'CONFIRM', cancelText = 'ABORT', danger = false }) {
const dlg = document.getElementById('dialog');
return new Promise((resolve) => {
const finish = (v) => {
dlg.close();
resolve(v);
};
clear(dlg).append(
h('div', { class: `panel dialog ${danger ? 'danger' : ''}` },
h('div', { class: 'panel-title' }, title),
h('div', { class: 'panel-body' }, ...[].concat(body).map((p) => h('p', null, p))),
h('div', { class: 'actions' },
h('button', { class: 'btn', type: 'button', onclick: () => finish(false) }, cancelText),
h('button', { class: `btn ${danger ? 'danger' : 'primary'}`, type: 'button', onclick: () => finish(true) }, confirmText),
),
),
);
dlg.oncancel = () => resolve(false);
dlg.showModal();
});
}
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 });
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function pad(n, width, ch = '0') {
return String(n).padStart(width, ch);
}
export function plural(n, one, many = `${one}S`) {
return `${n} ${n === 1 ? one : many}`;
}
+131
View File
@@ -0,0 +1,131 @@
// A small assembly editor: line numbers, syntax highlighting (a coloured <pre>
// underneath a transparent <textarea>), Tab-indents and error-line marking.
import { h } from './dom.js';
const MNEMONICS = new Set(
('nop yield halt ldi lui mov add sub mul div mod and or xor shl shr sar addi jmp beq bne blt bge ' +
'call ret push pop ldb ldh ldw stb sth stw in out li').split(' '),
);
const TOKEN = new RegExp(
[
'(\\.[A-Za-z_]\\w*)', // 1 directive
'([A-Za-z_]\\w*)(?=:)', // 2 label definition
'\\b(r(?:1[0-5]|\\d)|sp)\\b', // 3 register
'(-?\\b(?:0x[0-9a-fA-F]+|0b[01]+|\\d+)\\b)', // 4 number
'([A-Za-z_]\\w*)', // 5 identifier
'([\\[\\]+,:-])', // 6 punctuation
].join('|'),
'gi',
);
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const span = (cls, text) => `<span class="${cls}">${esc(text)}</span>`;
/** Highlight one line of assembly, returning HTML. Exported for testing. */
export function highlightLine(line) {
const c = line.search(/[;#]/);
const code = c < 0 ? line : line.slice(0, c);
const comment = c < 0 ? '' : line.slice(c);
let out = '';
let pos = 0;
TOKEN.lastIndex = 0;
for (let m; (m = TOKEN.exec(code)); ) {
out += esc(code.slice(pos, m.index));
pos = m.index + m[0].length;
if (m[1]) out += span('hl-dir', m[0]);
else if (m[2]) out += span('hl-lbl', m[0]);
else if (m[3]) out += span('hl-reg', m[0]);
else if (m[4]) out += span('hl-num', m[0]);
else if (m[5]) out += span(MNEMONICS.has(m[5].toLowerCase()) ? 'hl-mn' : 'hl-sym', m[0]);
else out += span('hl-pun', m[0]);
}
out += esc(code.slice(pos));
if (comment) out += span('hl-cmt', comment);
return out;
}
export function createEditor({ value = '', onInput = () => {}, onCursor = () => {} } = {}) {
const gutter = h('div', { class: 'ed-gutter', 'aria-hidden': 'true' });
const hl = h('div', { class: 'ed-hl', 'aria-hidden': 'true' });
const input = h('textarea', {
class: 'ed-input',
spellcheck: 'false',
autocapitalize: 'off',
autocomplete: 'off',
autocorrect: 'off',
wrap: 'off',
'aria-label': 'Assembly source code',
});
const el = h('div', { class: 'editor' }, gutter, h('div', { class: 'ed-body' }, hl, input));
let errorLine = null;
let escaped = false; // Escape pressed: let the next Tab leave the editor
function render() {
const lines = input.value.split('\n');
hl.innerHTML = lines
.map((l, i) => `<div class="ed-line${i + 1 === errorLine ? ' err' : ''}">${highlightLine(l) || ' '}</div>`)
.join('');
gutter.innerHTML = lines
.map((_, i) => `<div class="ed-num${i + 1 === errorLine ? ' err' : ''}">${i + 1}</div>`)
.join('');
syncScroll();
}
function syncScroll() {
hl.scrollTop = gutter.scrollTop = input.scrollTop;
hl.scrollLeft = input.scrollLeft;
}
function cursor() {
const upTo = input.value.slice(0, input.selectionStart);
const line = upTo.split('\n').length;
const col = upTo.length - upTo.lastIndexOf('\n');
onCursor({ line, col });
}
input.addEventListener('input', () => {
errorLine = null;
render();
onInput(input.value);
});
input.addEventListener('scroll', syncScroll);
for (const ev of ['keyup', 'click', 'focus']) input.addEventListener(ev, cursor);
input.addEventListener('blur', () => { escaped = false; });
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { escaped = true; return; }
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !escaped) {
e.preventDefault();
input.setRangeText(' ', input.selectionStart, input.selectionEnd, 'end');
input.dispatchEvent(new Event('input'));
}
});
input.value = value;
render();
return {
el,
get value() { return input.value; },
set value(v) { input.value = v; errorLine = null; render(); onInput(v); },
insertAtTop(text) {
input.value = text + input.value;
errorLine = null;
render();
onInput(input.value);
},
setError(line) {
errorLine = line;
render();
if (line) {
const lh = parseFloat(getComputedStyle(input).lineHeight) || 22;
input.scrollTop = Math.max(0, (line - 3) * lh);
syncScroll();
}
},
focus() { input.focus(); },
render, // re-measure after the element is attached
};
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffb62e"/><stop offset="1" stop-color="#ff3ad8"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="10" fill="#12072b"/>
<circle cx="32" cy="30" r="17" fill="url(#g)"/>
<rect x="12" y="35" width="40" height="2.5" fill="#12072b"/>
<rect x="12" y="41" width="40" height="3.5" fill="#12072b"/>
<path d="M6 50h52" stroke="#28f5ff" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 539 B

+103
View File
@@ -0,0 +1,103 @@
// Hex dump with an inspector: hover or click a byte to decode it.
import { h, pad } from './dom.js';
const hex2 = (b) => pad(b.toString(16).toUpperCase(), 2);
const printable = (b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '.');
/** Decode the bytes at offset i as little-endian integers. */
export function inspect(bytes, i) {
const b = (k) => (i + k < bytes.length ? bytes[i + k] : null);
const have = (n) => i + n <= bytes.length;
const out = { offset: i, u8: bytes[i] };
if (have(2)) {
const v = b(0) | (b(1) << 8);
out.u16 = v;
out.i16 = (v << 16) >> 16;
}
if (have(4)) {
const v = (b(0) | (b(1) << 8) | (b(2) << 16) | (b(3) << 24));
out.i32 = v;
out.u32 = v >>> 0;
}
return out;
}
export function describe(bytes, i) {
const d = inspect(bytes, i);
const parts = [`OFFSET 0x${pad(d.offset.toString(16).toUpperCase(), 4)} (${d.offset})`, `U8 ${d.u8}`];
if (d.i16 !== undefined) parts.push(`I16 ${d.i16}`);
if (d.i32 !== undefined) parts.push(`I32 ${d.i32}`, `U32 ${d.u32}`);
return parts.join(' | ');
}
/**
* Render a hex dump. Returns { el, inspector } where inspector is the element
* that shows the decoded value of the hovered/selected byte.
*/
export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
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' });
let pinned = null;
const cells = new Map();
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;
}
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' }, ' '));
}
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),
),
);
}
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'));
}
const shown = pinned ?? i;
inspector.textContent = shown === null ? 'HOVER OR CLICK A BYTE TO DECODE IT' : describe(bytes, shown);
};
const target = (e) => {
const t = e.target.closest('[data-i]');
return t ? Number(t.dataset.i) : null;
};
body.addEventListener('mouseover', (e) => { const i = target(e); if (i !== null && pinned === null) select(i, false); });
body.addEventListener('mouseleave', () => { if (pinned === null) select(null, false); });
body.addEventListener('click', (e) => { const i = target(e); if (i !== null) select(i, true); });
return { el: h('div', { class: 'hex' }, body, inspector), inspector };
}
/** Parse hex text ("de ad 0xBE, ef") into bytes; throws on bad input. */
export function parseHex(text) {
const cleaned = text.replace(/0x/gi, ' ').replace(/[,\s]+/g, ' ').trim();
if (!cleaned) return new Uint8Array(0);
const out = [];
for (const tok of cleaned.split(' ')) {
if (!/^[0-9a-fA-F]+$/.test(tok) || tok.length % 2) throw new Error(`"${tok}" is not whole bytes of hex`);
for (let i = 0; i < tok.length; i += 2) out.push(parseInt(tok.slice(i, i + 2), 16));
}
return Uint8Array.from(out);
}
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Halcyon Flight Control</title>
<meta name="description" content="Program, launch and command your ship in the belt.">
<meta name="color-scheme" content="dark">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
<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>
<div class="crt" aria-hidden="true"></div>
<div id="app">
<p class="boot">INITIALISING TERMINAL...</p>
</div>
<dialog id="dialog"></dialog>
<div id="toasts" role="status" aria-live="polite"></div>
<noscript>
<p class="boot">THIS TERMINAL REQUIRES JAVASCRIPT.</p>
</noscript>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
// 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();
}
+10
View File
@@ -0,0 +1,10 @@
// Ship status presentation shared by several views.
export const STATUS = {
inventory: { label: 'DOCKED', cls: 'amber', hint: 'On the dock. Can be programmed and launched.' },
launching: { label: 'LAUNCH PENDING', cls: 'mag', hint: 'Enters the belt on the next daily run.' },
active: { label: 'IN BELT', cls: 'cyan', hint: 'Flying its program.' },
destroyed: { label: 'LOST', cls: 'red', hint: 'Destroyed.' },
};
export const statusOf = (s) => STATUS[s] ?? { label: String(s).toUpperCase(), cls: '', hint: '' };
+391
View File
@@ -0,0 +1,391 @@
/* Halcyon Flight Control -- 1980s space terminal. */
:root {
--bg0: #07030f;
--bg1: #12072b;
--ink: #ece7ff;
--dim: #9a91c8;
--faint: #4a3f7a;
--cyan: #28f5ff;
--mag: #ff3ad8;
--amber: #ffb62e;
--lime: #7dff8a;
--red: #ff5577;
--violet: #b18cff;
--panel: rgba(13, 6, 34, 0.84);
--line: rgba(40, 245, 255, 0.5);
--mono: "VT323", "IBM Plex Mono", "SF Mono", "Cascadia Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
--display: Eurostile, "Microgramma D Extended", "Bank Gothic", Orbitron, "Avenir Next Condensed", "Arial Narrow", "Trebuchet MS", sans-serif;
}
* { box-sizing: border-box; }
html { color-scheme: dark; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%) fixed;
font-family: var(--mono);
font-size: 17px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ---- 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;
}
.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; } }
.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; }
/* ---- type ---------------------------------------------------------------- */
h1, h2, h3, p { margin: 0; }
.logo {
margin: 0; font-family: var(--display); font-style: italic; font-weight: 900;
letter-spacing: 0.16em; font-size: clamp(2.2rem, 9vw, 4.8rem); line-height: 1.05;
background: linear-gradient(180deg, #ffffff 0%, #d6fbff 36%, #ffb62e 50%, #ff3ad8 76%, #8a2cff 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 0 14px rgba(255, 58, 216, 0.55));
}
.logo.sm { font-size: 1.15rem; letter-spacing: 0.3em; filter: drop-shadow(0 0 8px rgba(255, 58, 216, 0.6)); }
.kicker { font-family: var(--display); letter-spacing: 0.5em; font-size: 0.8rem; color: var(--cyan); text-shadow: 0 0 8px var(--cyan); }
.tagline { letter-spacing: 0.3em; color: var(--dim); font-size: 0.95rem; margin-top: 0.4rem; }
.dim { color: var(--dim); }
.hint { color: var(--dim); font-size: 0.9rem; line-height: 1.4; }
.warn { color: var(--amber); }
.ok { color: var(--lime); }
.bad { color: var(--red); }
a { color: var(--cyan); }
a:hover { color: var(--mag); }
.boot { padding: 3rem 1rem; text-align: center; color: var(--lime); letter-spacing: 0.2em; }
.boot::after { content: "_"; animation: blink 1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.skip { position: absolute; left: -999px; top: 0; background: var(--amber); color: #000; padding: 0.5rem 1rem; z-index: 200; }
.skip:focus { left: 0; }
/* ---- layout -------------------------------------------------------------- */
.topbar {
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);
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); }
.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; }
.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);
border: 1px solid var(--faint); cursor: pointer;
}
button.tab { font-size: 0.85rem; }
.tab:hover { color: var(--ink); border-color: var(--mag); }
.tab.on { color: var(--bg0); background: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 16px rgba(40, 245, 255, 0.6); font-weight: 700; }
.shipsel { display: flex; align-items: center; gap: 0.6rem; font-size: 0.8rem; letter-spacing: 0.2em; color: var(--dim); }
.main { max-width: 1100px; margin: 0 auto; padding: 1rem 1rem 8rem; outline: none; }
.foot { position: relative; z-index: 10; width: max-content; max-width: 94vw; margin: 0 auto 1.5rem; padding: 0.35rem 1rem; text-align: center; color: var(--dim); font-size: 0.75rem; letter-spacing: 0.18em; background: rgba(7, 3, 15, 0.82); border: 1px solid var(--faint); }
.grid-2 { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 1.2rem; align-items: start; }
.stack { display: grid; gap: 0.8rem; }
.stack-lg { display: grid; gap: 1.2rem; }
.row { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.spacer { flex: 1; }
.pad { padding: 0.6rem 1rem; }
/* ---- panels -------------------------------------------------------------- */
.panel {
position: relative; background: var(--panel); border: 1px solid var(--line);
box-shadow: 0 0 26px rgba(40, 245, 255, 0.13), inset 0 0 36px rgba(255, 58, 216, 0.05);
}
.panel::before {
content: ""; position: absolute; inset: -3px; pointer-events: none;
--c: var(--mag);
background:
linear-gradient(var(--c), var(--c)) top left / 16px 2px,
linear-gradient(var(--c), var(--c)) top left / 2px 16px,
linear-gradient(var(--c), var(--c)) top right / 16px 2px,
linear-gradient(var(--c), var(--c)) top right / 2px 16px,
linear-gradient(var(--c), var(--c)) bottom left / 16px 2px,
linear-gradient(var(--c), var(--c)) bottom left / 2px 16px,
linear-gradient(var(--c), var(--c)) bottom right / 16px 2px,
linear-gradient(var(--c), var(--c)) bottom right / 2px 16px;
background-repeat: no-repeat;
}
.panel-title {
display: flex; align-items: center; gap: 0.8rem; padding: 0.55rem 1rem;
font-family: var(--display); font-size: 0.8rem; letter-spacing: 0.26em; text-transform: uppercase; color: var(--cyan);
text-shadow: 0 0 8px rgba(40, 245, 255, 0.7);
background: linear-gradient(90deg, rgba(255, 58, 216, 0.34), rgba(40, 245, 255, 0.08) 65%, transparent);
border-bottom: 1px solid var(--line);
}
summary.panel-title { cursor: pointer; list-style: none; }
summary.panel-title::-webkit-details-marker { display: none; }
summary.panel-title::before { content: "\25B6"; font-size: 0.7em; }
details[open] > summary.panel-title::before { content: "\25BC"; }
.panel-body { padding: 1rem; }
.panel-body.flush { padding: 0; }
.panel .dim.cursor, .panel-title .dim { text-shadow: none; letter-spacing: 0.1em; }
.empty { padding: 1.4rem 1rem; color: var(--dim); letter-spacing: 0.15em; }
/* ---- controls ------------------------------------------------------------ */
.btn {
font: inherit; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; text-decoration: none;
display: inline-block; text-align: center;
color: var(--cyan); background: rgba(40, 245, 255, 0.06);
border: 1px solid var(--cyan); padding: 0.5rem 1.1rem; cursor: pointer;
box-shadow: 0 0 10px rgba(40, 245, 255, 0.25);
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
}
.btn:hover:not(:disabled) { background: var(--cyan); color: var(--bg0); box-shadow: 0 0 20px var(--cyan); }
.btn.primary { color: #fff; border-color: var(--mag); background: rgba(255, 58, 216, 0.2); box-shadow: 0 0 12px rgba(255, 58, 216, 0.45); }
.btn.primary:hover:not(:disabled) { background: var(--mag); color: var(--bg0); box-shadow: 0 0 22px var(--mag); }
.btn.danger { color: #fff; border-color: var(--red); background: rgba(255, 85, 119, 0.2); box-shadow: 0 0 12px rgba(255, 85, 119, 0.45); }
.btn.danger:hover:not(:disabled) { background: var(--red); color: var(--bg0); box-shadow: 0 0 22px var(--red); }
.btn.small { padding: 0.2rem 0.7rem; font-size: 0.8rem; }
.btn.wide { width: 100%; }
.btn:disabled { opacity: 0.38; cursor: not-allowed; box-shadow: none; }
.btn:focus-visible, .tab:focus-visible, .linklike:focus-visible, a:focus-visible,
input:focus-visible, select:focus-visible, textarea:focus-visible, .keybox:focus-visible, .hx-body:focus-visible {
outline: 2px solid var(--amber); outline-offset: 2px;
}
.linklike { font: inherit; background: none; border: 0; color: var(--cyan); text-decoration: underline; cursor: pointer; padding: 0; }
label { display: block; font-size: 0.85rem; letter-spacing: 0.22em; color: var(--cyan); text-transform: uppercase; }
label.check { display: flex; align-items: center; gap: 0.6rem; letter-spacing: 0.12em; color: var(--ink); cursor: pointer; }
input[type="checkbox"] { accent-color: var(--mag); width: 1.1rem; height: 1.1rem; }
input[type="text"], input[type="password"], select, textarea {
width: 100%; font: inherit; color: var(--ink);
background: rgba(0, 0, 0, 0.5); border: 1px solid var(--faint); padding: 0.5rem 0.7rem;
caret-color: var(--amber); border-radius: 0;
}
select { width: auto; max-width: 100%; color: var(--cyan); border-color: var(--line); }
select option { background: var(--bg1); color: var(--ink); }
input:focus, select:focus, textarea:focus { border-color: var(--cyan); box-shadow: 0 0 14px rgba(40, 245, 255, 0.35); }
textarea { resize: vertical; }
.form-error { color: var(--red); min-height: 1.4em; letter-spacing: 0.06em; }
.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; }
.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); }
.badge.mag { color: var(--mag); animation: pulse 1.6s ease-in-out infinite; }
.badge.cyan { color: var(--cyan); }
.badge.red { color: var(--red); }
@keyframes pulse { 50% { box-shadow: 0 0 14px var(--mag); } }
/* ---- fleet --------------------------------------------------------------- */
.manifest { width: 100%; border-collapse: collapse; }
.manifest th { text-align: left; font-weight: 400; font-size: 0.8rem; letter-spacing: 0.2em; color: var(--dim); padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--line); }
.manifest th, .manifest td { white-space: nowrap; }
.manifest td { padding: 0.7rem 0.9rem; border-bottom: 1px dashed var(--faint); vertical-align: middle; }
.manifest tr.sel td { background: rgba(255, 58, 216, 0.09); }
.manifest tr.sel td:first-child { box-shadow: inset 3px 0 var(--mag); }
.manifest td.actions { text-align: right; white-space: normal; }
.manifest td.actions .btn + .btn { margin-left: 0.4rem; }
.steps { list-style: none; margin: 0; padding: 0.8rem 1rem 0; display: grid; gap: 0.7rem; }
.steps li { display: flex; gap: 0.8rem; align-items: baseline; }
.steps .n { flex: none; width: 1.7rem; height: 1.7rem; display: grid; place-items: center; border: 1px solid var(--mag); color: var(--mag); font-weight: 700; }
.steps strong { color: var(--amber); letter-spacing: 0.12em; }
/* ---- editor -------------------------------------------------------------- */
.editor {
--lh: 22px;
display: flex; height: min(60vh, 540px); min-height: 300px; margin: 0.6rem 0;
background: #05020c; border: 1px solid var(--line);
font-family: var(--mono); font-size: 16px; line-height: var(--lh);
}
.ed-gutter { flex: none; width: 3.6em; overflow: hidden; padding: 8px 0.7em 8px 0; text-align: right; color: #7266b0; background: rgba(255, 58, 216, 0.06); border-right: 1px solid var(--faint); user-select: none; }
.ed-num { height: var(--lh); }
.ed-num.err { color: var(--red); font-weight: 700; }
.ed-body { position: relative; flex: 1; min-width: 0; }
.ed-hl, .ed-input {
position: absolute; inset: 0; margin: 0; padding: 8px 12px; border: 0;
font: inherit; line-height: var(--lh); letter-spacing: 0; white-space: pre; tab-size: 8;
}
.ed-hl { overflow: hidden; pointer-events: none; color: var(--ink); }
.ed-line { height: var(--lh); white-space: pre; }
.ed-line.err { background: rgba(255, 85, 119, 0.22); box-shadow: inset 3px 0 var(--red); }
.ed-input { overflow: auto; resize: none; background: transparent; color: transparent; caret-color: var(--amber); outline: none; box-shadow: none; width: auto; }
.ed-input:focus { box-shadow: none; }
.ed-input::selection { background: rgba(40, 245, 255, 0.35); }
.ed-body:focus-within { box-shadow: inset 0 0 0 1px var(--cyan), 0 0 14px rgba(40, 245, 255, 0.3); }
.hl-mn { color: var(--mag); font-weight: 700; }
.hl-reg { color: var(--cyan); }
.hl-num { color: var(--amber); }
.hl-lbl { color: var(--lime); }
.hl-dir { color: var(--violet); }
.hl-sym { color: #ffd6f7; }
.hl-cmt { color: #8278bd; font-style: italic; }
.hl-pun { color: #a89fd6; }
.console { background: #05020c; border: 1px solid var(--faint); padding: 0.7rem 1rem; min-height: 5.4rem; margin-top: 0.6rem; }
.console p + p { margin-top: 0.2rem; }
.meter { height: 12px; border: 1px solid var(--cyan); background: #05020c; margin-top: 0.5rem; }
.meter .fill { height: 100%; width: 0; background: repeating-linear-gradient(90deg, var(--cyan) 0 6px, transparent 6px 8px); box-shadow: 0 0 10px var(--cyan); }
.meter.over { border-color: var(--red); }
.meter.over .fill { background: repeating-linear-gradient(90deg, var(--red) 0 6px, transparent 6px 8px); box-shadow: 0 0 10px var(--red); }
.refcard { margin: 0; padding: 1rem; overflow-x: auto; font-size: 0.85rem; line-height: 1.3; color: var(--lime); background: #05020c; }
/* ---- uplink -------------------------------------------------------------- */
.drop {
display: grid; place-items: center; gap: 0.3rem; padding: 2rem 1rem; text-align: center; cursor: pointer;
border: 2px dashed var(--cyan); background: rgba(40, 245, 255, 0.04);
letter-spacing: 0.15em; color: var(--ink); font-size: 1rem;
}
.drop-big { font-family: var(--display); letter-spacing: 0.25em; color: var(--cyan); }
.drop:hover, .drop.over { background: rgba(40, 245, 255, 0.12); box-shadow: 0 0 22px rgba(40, 245, 255, 0.4); border-color: var(--mag); }
.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-off { color: var(--amber); }
.hx-b { display: inline-block; width: 2ch; text-align: center; cursor: default; }
.hx-hex .hx-b { margin-right: 1ch; }
.hx-asc { color: var(--lime); }
.hx-asc .hx-b { width: 1ch; }
.hx-b.z { color: #5a4f96; }
.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-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; }
.nosignal .big { font-family: var(--display); font-size: 2.4rem; letter-spacing: 0.4em; color: var(--red); text-shadow: 0 0 16px var(--red); animation: blink 1.4s steps(1) infinite; }
/* ---- auth ---------------------------------------------------------------- */
.auth { min-height: 100vh; display: grid; place-content: center; justify-items: center; gap: 1.8rem; padding: 2rem 1rem 12rem; text-align: center; }
.auth-panel { width: min(460px, 92vw); text-align: left; }
.auth-panel .stack, .auth-panel .keyreveal { padding: 1.1rem; }
.auth-tabs { gap: 0; }
.auth-tabs .tab { flex: 1; text-align: center; border-top: 0; border-left: 0; border-right: 0; }
.keybox { display: block; padding: 0.9rem; font-size: 1.2rem; word-break: break-all; color: var(--amber); border: 1px solid var(--amber); background: rgba(255, 182, 46, 0.07); text-shadow: 0 0 8px rgba(255, 182, 46, 0.6); user-select: all; }
/* ---- toasts & dialog ----------------------------------------------------- */
#toasts { position: fixed; right: 1rem; bottom: 1rem; display: grid; gap: 0.6rem; z-index: 300; }
.toast { max-width: min(430px, 92vw); padding: 0.7rem 1rem; background: rgba(7, 3, 15, 0.96); border: 1px solid var(--cyan); box-shadow: 0 0 18px rgba(40, 245, 255, 0.4); letter-spacing: 0.05em; animation: slide-in 0.25s ease-out; }
.toast.err { border-color: var(--red); box-shadow: 0 0 18px rgba(255, 85, 119, 0.5); color: #ffd0d9; }
.toast.out { opacity: 0; transition: opacity 0.4s; }
@keyframes slide-in { from { transform: translateX(30px); opacity: 0; } }
dialog { background: transparent; border: 0; padding: 0; color: inherit; max-width: 100vw; }
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 .actions { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0 1rem 1rem; }
/* ---- small screens ------------------------------------------------------- */
@media (max-width: 900px) {
.grid-2, .uplink-grid, .fleet-grid { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 720px) {
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; }
.auth { padding-bottom: 10rem; }
.top-user { margin-left: 0; }
.manifest thead { display: none; }
.manifest tr { display: block; padding: 0.6rem 0; border-bottom: 1px dashed var(--faint); }
.manifest td { display: flex; justify-content: space-between; align-items: center; gap: 1rem; border: 0; padding: 0.25rem 1rem; }
.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; }
.editor { height: 52vh; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
+118
View File
@@ -0,0 +1,118 @@
// Login and registration.
import { api, keyStore } from './api.js';
import { h, clear, download, toast } from './dom.js';
export function authView({ onLogin }) {
let mode = 'login';
const host = h('section', { class: 'auth' });
const brand = h('header', { class: 'brand' },
h('p', { class: 'kicker' }, 'HALCYON INSTRUMENT & CONTROL'),
h('h1', { class: 'logo' }, 'FLIGHT CONTROL'),
h('p', { class: 'tagline' }, 'BELT UPLINK TERMINAL // MODEL HC-33'),
);
const panel = h('div', { class: 'panel auth-panel' });
host.append(brand, panel);
function tabs() {
const tab = (id, label) => h('button', {
class: `tab ${mode === id ? 'on' : ''}`, type: 'button', role: 'tab',
'aria-selected': mode === id ? 'true' : 'false',
onclick: () => { mode = id; render(); },
}, label);
return h('div', { class: 'tabs auth-tabs', role: 'tablist' }, tab('login', 'LOGIN'), tab('register', 'NEW ACCOUNT'));
}
function render() {
clear(panel).append(tabs(), mode === 'login' ? loginForm() : registerForm());
panel.querySelector('input')?.focus();
}
function loginForm() {
const err = h('p', { class: 'form-error', role: 'alert' });
const key = h('input', { id: 'key', type: 'password', autocomplete: 'current-password', required: true, spellcheck: 'false' });
const show = h('input', { type: 'checkbox', id: 'show', onchange: (e) => { key.type = e.target.checked ? 'text' : 'password'; } });
const btn = h('button', { class: 'btn primary wide', type: 'submit' }, 'ENGAGE LINK');
return h('form', {
class: 'stack',
onsubmit: async (e) => {
e.preventDefault();
const k = key.value.trim();
if (!k) return;
btn.disabled = true;
err.textContent = '';
try {
await api.me(k);
keyStore.set(k);
onLogin();
} catch (ex) {
err.textContent = ex.status === 401 ? 'ACCESS DENIED: KEY NOT RECOGNISED' : `ERROR: ${ex.message}`;
btn.disabled = false;
}
},
},
h('label', { for: 'key' }, 'ACCESS KEY'),
key,
h('label', { class: 'check', for: 'show' }, show, 'SHOW KEY'),
err,
btn,
h('p', { class: 'hint' }, 'Your access key was shown once, when you registered. There is no password: the key is the login.'),
);
}
function registerForm() {
const err = h('p', { class: 'form-error', role: 'alert' });
const name = h('input', { id: 'name', type: 'text', maxlength: '40', required: true, autocomplete: 'username', spellcheck: 'false' });
const btn = h('button', { class: 'btn primary wide', type: 'submit' }, 'REGISTER');
return h('form', {
class: 'stack',
onsubmit: async (e) => {
e.preventDefault();
const callsign = name.value.trim();
if (!callsign) { err.textContent = 'ENTER A CALLSIGN'; return; }
btn.disabled = true;
err.textContent = '';
try {
const res = await api.register(callsign);
showKey(callsign, res);
} catch (ex) {
err.textContent = ex.status === 409 ? 'CALLSIGN ALREADY IN USE' : `ERROR: ${ex.message}`;
btn.disabled = false;
}
},
},
h('label', { for: 'name' }, 'CALLSIGN'),
name,
h('p', { class: 'hint' }, '1 to 40 characters. You will be issued one command ship and a secret access key.'),
err,
btn,
);
}
function showKey(callsign, res) {
const enter = h('button', { class: 'btn primary wide', type: 'button', disabled: true }, 'ENTER FLIGHT CONTROL');
const ack = h('input', { type: 'checkbox', id: 'ack', onchange: (e) => { enter.disabled = !e.target.checked; } });
enter.addEventListener('click', () => { keyStore.set(res.api_key); onLogin(); });
clear(panel).append(
h('div', { class: 'panel-title' }, 'ACCOUNT CREATED'),
h('div', { class: 'stack keyreveal' },
h('p', null, `WELCOME, ${callsign.toUpperCase()}. COMMAND SHIP #${res.ship_id} IS WAITING ON THE DOCK.`),
h('p', { class: 'warn' }, 'THIS IS YOUR ACCESS KEY. IT IS SHOWN ONCE AND CANNOT BE RECOVERED.'),
h('code', { class: 'keybox', tabindex: '0' }, res.api_key),
h('div', { class: 'row' },
h('button', { class: 'btn', type: 'button', onclick: async () => {
try { await navigator.clipboard.writeText(res.api_key); toast('KEY COPIED TO CLIPBOARD'); } catch { toast('COPY FAILED: SELECT THE KEY AND COPY IT BY HAND', 'err'); }
} }, 'COPY'),
h('button', { class: 'btn', type: 'button', onclick: () => download(`halcyon-key-${callsign}.txt`, `${res.api_key}\n`, 'text/plain') }, 'SAVE AS FILE'),
),
h('label', { class: 'check', for: 'ack' }, ack, 'I HAVE STORED MY KEY SAFELY'),
enter,
),
);
}
render();
return host;
}
+139
View File
@@ -0,0 +1,139 @@
// Code editor: write assembly, check it, download it, seal it into a ship.
import { api, b64ToBytes } from './api.js';
import { h, clear, toast, confirmDialog, download } 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 */ } };
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';
const cursor = h('span', { class: 'dim' }, 'LN 1 COL 1');
let timer = 0;
const editor = createEditor({
value: load(key) ?? '',
onInput: (v) => { clearTimeout(timer); timer = setTimeout(() => save(key, v), 300); },
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' },
h('p', { class: 'dim' }, 'READY. ASSEMBLE TO CHECK YOUR PROGRAM.'));
const say = (...nodes) => clear(output).append(...nodes);
let assembled = null;
async function assemble() {
try {
const r = await api.assemble(editor.value);
editor.setError(null);
const bytes = b64ToBytes(r.program);
assembled = { bytes, ...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) })),
);
output.querySelector('.fill')?.style.setProperty('width', `${pct}%`);
return assembled;
} catch (e) {
assembled = null;
const m = /^line (\d+):/.exec(e.message);
if (m) editor.setError(Number(m[1]));
say(h('p', { class: 'bad' }, `ERROR: ${e.message}`));
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' });
const toolbar = h('div', { class: 'toolbar' },
exampleSel,
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')),
);
// ---- reference ---------------------------------------------------------
const card = h('pre', { class: 'refcard' }, 'LOADING...');
const ref = h('details', { class: 'panel ref', onToggle: async (e) => {
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();
} 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')),
);
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,
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.'),
output,
),
),
ref,
);
queueMicrotask(() => editor.render());
return view;
}
+59
View File
@@ -0,0 +1,59 @@
// Downlink viewer: hex dump of the ship's transmit buffer, with download.
import { api } from './api.js';
import { h, clear, toast, download } from './dom.js';
import { hexView } from './hexview.js';
export function downlinkView(ctx) {
const ship = ctx.ship;
const body = h('div', { class: 'panel-body' }, h('p', { class: 'dim' }, ship ? 'ACQUIRING SIGNAL...' : 'YOU HAVE NO SHIP.'));
const meta = h('span', { class: 'dim' });
const dl = h('button', { class: 'btn', type: 'button', disabled: true }, 'DOWNLOAD .BIN');
const refresh = h('button', { class: 'btn', type: 'button', disabled: !ship }, 'REFRESH');
let current = null;
async function fetchIt() {
if (!ship) return;
refresh.disabled = true;
try {
const { bytes, day } = await api.downlink(ship.id);
current = { bytes, day };
const allZero = bytes.every((b) => b === 0);
meta.textContent = `SHIP #${ship.id} // FROM THE RUN OF DAY ${day} // ${bytes.length} BYTES`;
dl.disabled = false;
clear(body).append(
...(allZero ? [h('p', { class: 'warn' }, 'BUFFER IS ALL ZEROS: THE SHIP HAS NOT WRITTEN ANYTHING TO ITS DOWNLINK BUFFER.')] : []),
hexView(bytes).el,
h('p', { class: 'hint' }, 'OFFSETS ARE INTO THE 1 KB DOWNLINK BUFFER (SHIP RAM 1024-2047). MULTI-BYTE VALUES ARE LITTLE-ENDIAN.'),
);
} catch (e) {
current = null;
dl.disabled = true;
meta.textContent = '';
clear(body).append(
e.status === 404
? h('div', { class: 'nosignal' },
h('p', { class: 'big' }, 'NO SIGNAL'),
h('p', { class: 'dim' }, 'THIS SHIP HAS NOT TRANSMITTED YET. A SHIP\'S FIRST DOWNLINK ARRIVES AFTER ITS FIRST DAY IN THE BELT.'))
: h('p', { class: 'bad' }, `ERROR: ${e.message}`),
);
} finally {
refresh.disabled = false;
}
}
// Reloading the account too keeps the ship's status and the day current.
refresh.addEventListener('click', () => ctx.refresh().catch((e) => toast(e.message, 'err')));
dl.addEventListener('click', () => {
if (!current) return;
download(`ship-${ship.id}-day-${current.day}-downlink.bin`, current.bytes);
toast(`SAVED ${current.bytes.length} BYTES`);
});
fetchIt();
return h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'DOWNLINK RECEIVER', h('span', { class: 'spacer' }), meta),
h('div', { class: 'toolbar pad' }, refresh, h('span', { class: 'spacer' }), dl),
body,
);
}
+84
View File
@@ -0,0 +1,84 @@
// Fleet manifest with launch controls.
import { api } from './api.js';
import { h, toast, confirmDialog } 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.',
],
confirmText: 'LAUNCH',
danger: true,
});
if (!ok) return;
try {
await api.launch(ship.id);
toast(`SHIP #${ship.id} CLEARED FOR LAUNCH. IT FLIES ON THE NEXT DAILY RUN.`);
await ctx.refresh();
} catch (e) {
toast(`LAUNCH FAILED: ${e.message}`, 'err');
}
}
const btn = (label, onclick, opts = {}) =>
h('button', { class: `btn small ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
function actions(s) {
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',
}));
}
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')));
return out;
}
const rows = ships.map((s) => {
const st = statusOf(s.status);
return h('tr', { class: s.id === selected ? 'sel' : '' },
h('td', { 'data-label': 'SHIP' }, h('button', { class: 'linklike', type: 'button', onclick: () => ctx.selectShip(s.id) }, `#${s.id}`)),
h('td', { 'data-label': 'STATUS' }, h('span', { class: `badge ${st.cls}`, title: st.hint }, st.label)),
h('td', { 'data-label': 'PROGRAM' }, s.program_bytes ? `${s.program_bytes} BYTES` : h('span', { class: 'dim' }, 'NONE')),
h('td', { 'data-label': 'LAST DOWNLINK' }, s.downlink_day >= 0 ? `DAY ${s.downlink_day}` : h('span', { class: 'dim' }, '--')),
h('td', { class: 'actions', 'data-label': '' }, actions(s)),
);
});
const step = (n, title, text) => h('li', null, h('span', { class: 'n' }, n), h('div', null, h('strong', null, title), ' ', text));
return h('div', { class: 'grid-2 fleet-grid' },
h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'FLEET MANIFEST'),
h('div', { class: 'panel-body flush' },
ships.length
? h('table', { class: 'manifest' },
h('thead', null, h('tr', null, ['SHIP', 'STATUS', 'PROGRAM', 'LAST DOWNLINK', ''].map((t) => h('th', { scope: 'col' }, t)))),
h('tbody', null, rows))
: h('p', { class: 'empty' }, 'NO SHIPS ON RECORD.'),
),
),
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('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')),
),
);
}
+101
View File
@@ -0,0 +1,101 @@
// Uplink form: choose a binary file (or type hex) and transmit it.
import { api } from './api.js';
import { h, clear, toast } from './dom.js';
import { hexView, parseHex } from './hexview.js';
export function uplinkView(ctx) {
const ship = ctx.ship;
const limit = ctx.state.info?.comm_bytes ?? 1024;
const open = ship && (ship.status === 'launching' || ship.status === 'active');
let bytes = null;
let label = '';
const preview = h('div', { class: 'uplink-preview' });
const meter = h('div', { class: 'meter', role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': String(limit), 'aria-label': 'Message size' }, h('div', { class: 'fill' }));
const sizeText = h('p', { class: 'dim' }, `NO MESSAGE LOADED. LIMIT ${limit} BYTES.`);
const err = h('p', { class: 'form-error', role: 'alert' });
const send = h('button', { class: 'btn primary', type: 'button', disabled: true }, ship ? `TRANSMIT TO SHIP #${ship.id}` : 'NO SHIP');
function set(b, from) {
bytes = b;
label = from;
err.textContent = '';
const over = b.length > limit;
meter.classList.toggle('over', over);
meter.setAttribute('aria-valuenow', String(b.length));
meter.querySelector('.fill').style.setProperty('width', `${Math.min(100, (b.length / limit) * 100)}%`);
sizeText.textContent = `${from}: ${b.length} BYTES OF ${limit}${over ? ' -- TOO LARGE' : ''}`;
if (over) err.textContent = `MESSAGE EXCEEDS THE ${limit}-BYTE LINK. REMOVE ${b.length - limit} BYTES.`;
else if (b.length === 0) err.textContent = 'MESSAGE IS EMPTY.';
send.disabled = !open || over || b.length === 0;
clear(preview);
if (b.length) {
preview.append(hexView(b, { rows: 8 }).el);
if (b.length > 128) preview.append(h('p', { class: 'hint' }, `SHOWING THE FIRST 128 OF ${b.length} BYTES.`));
}
}
// ---- file chooser / drop zone ------------------------------------------
const file = h('input', { type: 'file', id: 'uplink-file', class: 'sr-only' });
const drop = h('label', { class: `drop ${open ? '' : 'disabled'}`, for: 'uplink-file' },
h('span', { class: 'drop-big' }, 'DROP A BINARY FILE HERE'),
h('span', { class: 'dim' }, 'or click to choose one'),
);
file.disabled = !open;
const takeFile = async (f) => {
if (!f) return;
if (f.size > 1 << 20) { err.textContent = 'THAT FILE IS FAR TOO LARGE FOR THE LINK.'; return; }
set(new Uint8Array(await f.arrayBuffer()), f.name.toUpperCase());
};
file.addEventListener('change', () => takeFile(file.files[0]));
for (const ev of ['dragenter', 'dragover']) drop.addEventListener(ev, (e) => { e.preventDefault(); if (open) drop.classList.add('over'); });
for (const ev of ['dragleave', 'drop']) drop.addEventListener(ev, () => drop.classList.remove('over'));
drop.addEventListener('drop', (e) => { e.preventDefault(); if (open) takeFile(e.dataTransfer.files[0]); });
// ---- hex entry ---------------------------------------------------------
const hexIn = h('textarea', { id: 'hex-in', rows: '3', spellcheck: 'false', placeholder: 'DE AD BE EF 00 01 ...', disabled: !open, 'aria-label': 'Message as hexadecimal' });
const useHex = h('button', { class: 'btn small', type: 'button', disabled: !open, onclick: () => {
try { set(parseHex(hexIn.value), 'HEX ENTRY'); } catch (e) { err.textContent = `HEX ERROR: ${e.message}`; }
} }, 'USE HEX');
send.addEventListener('click', async () => {
send.disabled = true;
try {
await api.setUplink(ship.id, bytes);
toast(`UPLINK QUEUED FOR SHIP #${ship.id}: ${bytes.length} BYTES. DELIVERED AT THE START OF THE NEXT RUN.`);
} catch (e) {
err.textContent = `TRANSMISSION FAILED: ${e.message}`;
send.disabled = false;
}
});
const status = !ship
? 'YOU HAVE NO SHIP.'
: open
? `TARGET: SHIP #${ship.id}. A NEW UPLINK REPLACES ONE ALREADY QUEUED.`
: `SHIP #${ship.id} IS ${ship.status.toUpperCase()}. UPLINKS CAN ONLY BE SENT TO SHIPS THAT ARE LAUNCHING OR IN THE BELT.`;
return h('div', { class: 'grid-2 uplink-grid' },
h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'UPLINK TRANSMITTER'),
h('div', { class: 'panel-body stack' },
h('p', { class: open ? 'hint' : 'warn' }, status),
drop, file,
h('label', { for: 'hex-in' }, 'OR ENTER HEX'),
hexIn,
h('div', null, useHex),
meter, sizeText, err,
h('div', null, send),
),
),
h('section', { class: 'panel' },
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.'),
),
),
);
}
+56
View File
@@ -0,0 +1,56 @@
// Package web serves the browser front end, the manual and sample programs.
package web
import (
"embed"
"encoding/json"
"io/fs"
"net/http"
"sort"
"strings"
"wh/docs"
"wh/examples"
)
//go:embed static
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 {
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 /examples/index.json", func(w http.ResponseWriter, r *http.Request) {
entries, _ := fs.ReadDir(examples.FS, ".")
names := []string{}
for _, e := range entries {
if n, ok := strings.CutSuffix(e.Name(), ".s"); ok {
names = append(names, n)
}
}
sort.Strings(names)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(names)
})
mux.Handle("GET /examples/", http.StripPrefix("/examples/", http.FileServerFS(examples.FS)))
return secure(mux)
}
// secure adds headers appropriate to a page that loads only its own assets.
func secure(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hd := w.Header()
hd.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
hd.Set("X-Content-Type-Options", "nosniff")
hd.Set("Referrer-Policy", "no-referrer")
h.ServeHTTP(w, r)
})
}
+39
View File
@@ -0,0 +1,39 @@
// Package world defines the static shape of the belt: bodies on analytic
// (on-rails) orbits and the procedural generation that creates them.
//
// The star pulls with acceleration v^2/r (v = config OrbitSpeed), so a circular
// orbit at any radius has the same speed v. Every body on rails is therefore
// on a circle traversed at speed v; only the period (2*pi*r/v) varies.
package world
import "wh/fixed"
// Orbit is a circular orbit around the origin (the star).
type Orbit struct {
A fixed.F // radius, km
Period int64 // seconds
M0 fixed.F // phase at t=0, radians
Inc fixed.F // inclination to the ecliptic (XY plane), radians
Node fixed.F // longitude of the ascending node, radians
}
// PeriodFor returns the period in seconds of a circle of radius a at speed v.
func PeriodFor(a, v fixed.F) int64 {
return fixed.TwoPi.Mul(a).Div(v).Int()
}
// State returns position (km) and velocity (km/s) at absolute time t seconds.
func (o Orbit) State(t int64) (pos, vel fixed.Vec) {
frac := fixed.FromInt(t % o.Period).Div(fixed.FromInt(o.Period))
s, c := fixed.SinCos(o.M0 + fixed.TwoPi.Mul(frac))
k := fixed.TwoPi.Mul(o.A).Div(fixed.FromInt(o.Period)) // speed
pos = fixed.Vec{X: o.A.Mul(c), Y: o.A.Mul(s)}
vel = fixed.Vec{X: -k.Mul(s), Y: k.Mul(c)}
return o.orient(pos), o.orient(vel)
}
// orient rotates a vector from the orbital plane into the ecliptic frame:
// Rz(Node) * Rx(Inc).
func (o Orbit) orient(v fixed.Vec) fixed.Vec {
return v.RotateX(o.Inc).RotateZ(o.Node)
}
+91
View File
@@ -0,0 +1,91 @@
package world
import (
"wh/config"
"wh/fixed"
"wh/rng"
)
// Ore identifies a raw material.
type Ore int
const (
Iron Ore = iota
Nickel
Ice
Platinum
NumOre
)
func (o Ore) String() string {
return [...]string{"iron", "nickel", "ice", "platinum"}[o]
}
type Asteroid struct {
ID int64
Orbit Orbit
Ore [NumOre]int64 // remaining kilograms of each ore
}
func (a *Asteroid) TotalOre() int64 {
var t int64
for _, v := range a.Ore {
t += v
}
return t
}
// Station is the dropoff point, on a circular orbit in the ecliptic plane.
type Station struct {
Orbit Orbit
}
// Stream labels keep independent generation phases from sharing randomness.
const (
labelBelt = iota + 1
labelStation
)
// Generate builds the asteroid field and station from the config seed.
func Generate(cfg config.Config) ([]Asteroid, Station) {
r := rng.Derive(cfg.Seed, labelBelt)
asts := make([]Asteroid, cfg.AsteroidCount)
for i := range asts {
a := r.FixedRange(cfg.BeltInner, cfg.BeltOuter)
asts[i] = Asteroid{
ID: int64(i + 1),
Orbit: Orbit{
A: a,
Period: PeriodFor(a, cfg.OrbitSpeed),
M0: r.FixedRange(0, fixed.TwoPi),
Node: r.FixedRange(0, fixed.TwoPi),
// Bias inclination low (squared uniform).
Inc: r.Fixed().Mul(r.Fixed()).Mul(cfg.MaxInclination),
},
}
// Each asteroid has a total mass and a random mix of ores.
total := int64(50_000) + int64(r.Intn(950_000))
var weights [NumOre]uint64
var wsum uint64
for o := range weights {
weights[o] = r.Intn(100) + 1
wsum += weights[o]
}
// Platinum is rare: only a fifth of asteroids carry much of it.
if r.Intn(5) != 0 {
wsum -= weights[Platinum]
weights[Platinum] = 0
}
for o := range weights {
asts[i].Ore[o] = total * int64(weights[o]) / int64(wsum)
}
}
sr := rng.Derive(cfg.Seed, labelStation)
a := (cfg.BeltInner + cfg.BeltOuter).DivInt(2)
st := Station{Orbit: Orbit{
A: a,
Period: PeriodFor(a, cfg.OrbitSpeed),
M0: sr.FixedRange(0, fixed.TwoPi),
}}
return asts, st
}
+78
View File
@@ -0,0 +1,78 @@
package world
import (
"math"
"testing"
"wh/config"
"wh/fixed"
)
func TestOrbitsAreCirclesAtCommonSpeed(t *testing.T) {
cfg := config.Default()
asts, _ := Generate(cfg)
want := cfg.OrbitSpeed.Float64()
for _, a := range asts {
o := a.Orbit
for _, ts := range []int64{0, 100_000, 777_777, o.Period / 3, o.Period - 1} {
p, v := o.State(ts)
if r := p.Len().Float64(); math.Abs(r-o.A.Float64()) > 1e-3 {
t.Fatalf("asteroid %d t=%d: radius %g, want %g", a.ID, ts, r, o.A.Float64())
}
if s := v.Len().Float64(); math.Abs(s-want)/want > 1e-5 {
t.Fatalf("asteroid %d t=%d: speed %g, want %g", a.ID, ts, s, want)
}
if d := p.Dot(v).Float64() / (p.Len().Float64() * want); math.Abs(d) > 1e-4 {
t.Fatalf("asteroid %d: velocity not tangent (cos=%g)", a.ID, d)
}
// Inclination bounds the height above the ecliptic.
if z := p.Z.Abs().Float64(); z > o.A.Float64()*math.Sin(o.Inc.Float64())*1.001+1e-3 {
t.Fatalf("asteroid %d: |z|=%g exceeds inclination bound", a.ID, z)
}
}
// Periodic.
p1, _ := o.State(12345)
p2, _ := o.State(12345 + o.Period)
if d := p1.Sub(p2).Len().Float64(); d > 1 {
t.Fatalf("asteroid %d not periodic, off by %g km", a.ID, d)
}
}
}
func TestOrbitPlaneMatchesInclination(t *testing.T) {
o := Orbit{A: fixed.FromInt(2_000_000), Inc: fixed.FromRatio(3, 10), Node: fixed.FromInt(2)}
o.Period = PeriodFor(o.A, fixed.FromInt(3))
// The orbit normal is Rz(Node)Rx(Inc) applied to +Z; positions must be
// perpendicular to it, and the maximum height is A*sin(Inc).
n := fixed.Vec{Z: fixed.One}.RotateX(o.Inc).RotateZ(o.Node)
var maxZ float64
for k := int64(0); k < 50; k++ {
p, _ := o.State(k * o.Period / 50)
if d := p.Dot(n).Float64(); math.Abs(d) > 1e-2 {
t.Fatalf("position not in the orbital plane: p.n = %g", d)
}
maxZ = math.Max(maxZ, p.Z.Float64())
}
if want := o.A.Float64() * math.Sin(0.3); math.Abs(maxZ-want)/want > 0.01 {
t.Fatalf("max z = %g, want %g", maxZ, want)
}
}
func TestGenerateDeterministic(t *testing.T) {
cfg := config.Default()
a1, s1 := Generate(cfg)
a2, s2 := Generate(cfg)
if len(a1) != cfg.AsteroidCount || s1 != s2 {
t.Fatal("mismatch")
}
for i := range a1 {
if a1[i] != a2[i] {
t.Fatalf("asteroid %d differs", i)
}
}
cfg.Seed = 2
a3, _ := Generate(cfg)
if a3[0] == a1[0] {
t.Fatal("different seeds should differ")
}
}