init
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
web/e2e
|
||||
node_modules
|
||||
+43
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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), ®)
|
||||
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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+25
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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: '' };
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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')),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user