This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+311
View File
@@ -0,0 +1,311 @@
package studio
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/planet"
)
// Watching a bake, which is the part `terrain bake` on a terminal cannot do.
//
// A bake is two hours, and for most of that the only thing on screen is a percentage. The question an author
// actually has - *is this the world I meant* - is answerable long before the end, because the solve is
// decomposed per landmass (D-53) and each one comes out whole. So the studio hangs a hook on the composite:
// every time a region's land is written back, the planet as it stands is rendered to a preview, and the world
// fills in one landmass at a time while the rest of it is still running. If the first continent out is wrong,
// the other seventeen do not need to finish.
//
// Two consequences worth stating. The hook holds the composite lock, so every worker is stopped while it
// draws - about a second a region against a run measured in hours, which is the right trade for being able to
// see it at all. And a bake can be **cancelled**, because a two-hour job with no way out is not a button
// anybody should press: the solve checks once a step, so the longest wait is one step of the biggest region,
// and a cancelled result is for looking at rather than for writing out.
// bakeRun is the one bake a studio will run at a time.
type bakeRun struct {
mu sync.Mutex
running bool
finished bool
cancelCh chan struct{}
started time.Time
steps int
total int // regions this run will solve
done []planet.RegionResult
lines []string
stamp int64 // bumped every time a new preview lands, so the browser knows to re-fetch
outDir string
err string
note string
}
// maxBakeLines caps the log the browser is shown. A thousand-step bake over eighteen regions prints a couple
// of hundred lines; the cap is only so that a pathological run cannot grow without bound.
const maxBakeLines = 400
func (b *bakeRun) logf(format string, a ...any) {
line := fmt.Sprintf(format, a...)
b.mu.Lock()
b.lines = append(b.lines, line)
if len(b.lines) > maxBakeLines {
b.lines = b.lines[len(b.lines)-maxBakeLines:]
}
b.mu.Unlock()
}
type bakeStatus struct {
Running bool `json:"running"`
Finished bool `json:"finished"`
Seconds float64 `json:"seconds"`
Steps int `json:"steps"`
Total int `json:"total"`
Done []planet.RegionResult `json:"done"`
Lines []string `json:"lines"`
Stamp int64 `json:"stamp"`
OutDir string `json:"out_dir"`
Err string `json:"err"`
Note string `json:"note"`
}
func (s *Server) handleBake(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.bake.mu.Lock()
// Both slices start empty rather than nil, because a nil slice marshals to `null` and the page does
// `j.done.length` on it - which is fine for every poll after the first region lands and throws on
// every poll before it, which is exactly the window a person watches most closely.
st := bakeStatus{
Running: s.bake.running, Finished: s.bake.finished,
Steps: s.bake.steps, Total: s.bake.total,
Done: append(make([]planet.RegionResult, 0, len(s.bake.done)), s.bake.done...),
Lines: append(make([]string, 0, len(s.bake.lines)), s.bake.lines...),
Stamp: s.bake.stamp,
OutDir: s.bake.outDir, Err: s.bake.err, Note: s.bake.note,
}
if !s.bake.started.IsZero() {
st.Seconds = time.Since(s.bake.started).Seconds()
}
s.bake.mu.Unlock()
writeJSON(w, st)
case http.MethodPost:
var req struct {
Only []int `json:"only"`
Steps int `json:"steps"`
Jobs int `json:"jobs"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if err := s.startBake(req.Only, req.Steps, req.Jobs); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
writeJSON(w, map[string]any{"ok": true})
default:
http.Error(w, "GET or POST", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleBakeCancel(w http.ResponseWriter, r *http.Request) {
s.bake.mu.Lock()
if s.bake.running && s.bake.cancelCh != nil {
select {
case <-s.bake.cancelCh: // already asked
default:
close(s.bake.cancelCh)
s.bake.note = "cancelling: regions in flight stop at the end of their current step"
}
}
s.bake.mu.Unlock()
writeJSON(w, map[string]any{"ok": true})
}
func (s *Server) handleBakePreview(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(s.planDir, bakePreviewName))
}
const bakePreviewName = "bake_preview.png"
// bakePreviewWidth is what the live preview is drawn at. Small on purpose: it is redrawn with every worker
// stopped, so it is charged against the bake's wall time, and 1400 px is enough to answer "is this the world
// I meant" while costing well under a second.
const bakePreviewWidth = 1400
// startBake takes a snapshot of everything the run needs and hands it to a goroutine.
//
// A snapshot rather than a reference, because the whole point of the studio is that the painting keeps being
// edited: a bake is of the world as it was when the button was pressed, and it says so.
func (s *Server) startBake(only []int, steps, jobs int) error {
s.bake.mu.Lock()
if s.bake.running {
s.bake.mu.Unlock()
return fmt.Errorf("a bake is already running; cancel it first")
}
s.bake.running, s.bake.finished = true, false
s.bake.cancelCh = make(chan struct{})
s.bake.started = time.Now()
s.bake.done, s.bake.lines, s.bake.err, s.bake.outDir, s.bake.note = nil, nil, "", "", ""
s.bake.total, s.bake.steps = 0, steps
cancel := s.bake.cancelCh
s.bake.mu.Unlock()
s.mu.Lock()
// Copied rather than shared: a bake is hours and the author keeps painting through it, so what it solves
// has to be the world as it was when they pressed the button.
art := &planet.Painting{
Class: append([]uint8(nil), s.paint...),
ClassW: s.paintW, ClassH: s.paintH,
}
if s.ov != nil {
art.Overlay = append([]uint8(nil), s.ovPaint...)
art.OverlayAlpha = append([]uint8(nil), s.ovAlpha...)
art.OverlayW, art.OverlayH = s.paintW, s.paintH
}
mPath := s.manifestPath
s.mu.Unlock()
go s.runBake(art, mPath, only, steps, jobs, cancel)
return nil
}
func (s *Server) runBake(art *planet.Painting, mPath string, only []int, steps, jobs int,
cancel chan struct{}) {
fail := func(err error) {
s.bake.mu.Lock()
s.bake.err = err.Error()
s.bake.running, s.bake.finished = false, true
s.bake.mu.Unlock()
}
// Loaded fresh rather than reusing the server's copy: a bake is long enough that the manifest may be
// edited while it runs, and it should be of the numbers that were in force when it started.
m, err := manifest.Load(mPath)
if err != nil {
fail(err)
return
}
s.bake.logf("preparing")
in, err := planet.PrepareWith(m, art, s.bake.logf)
if err != nil {
fail(err)
return
}
wanted := len(in.Part.Regions)
if len(only) > 0 {
wanted = len(only)
}
s.bake.mu.Lock()
s.bake.total = wanted
s.bake.mu.Unlock()
res, err := planet.Bake(in, planet.BakeOptions{
Only: only, Steps: steps, Jobs: jobs, Log: s.bake.logf, Cancel: cancel,
OnRegion: func(res *planet.Result, rr planet.RegionResult) {
s.writeBakePreview(res)
s.bake.mu.Lock()
s.bake.done = append(s.bake.done, rr)
s.bake.stamp = time.Now().UnixNano()
s.bake.mu.Unlock()
},
})
if err != nil {
fail(err)
return
}
note := ""
out := ""
if res.Cancelled() {
// A cancelled run is not worthless, and the first version of this threw it away, which was wrong: the
// solve is per landmass, so a region that *finished* is finished - only the one or two still in
// flight stopped mid-step. Cancelling an eighteen-region bake after fifteen of them had landed and
// getting nothing for it is exactly the outcome a cancel button should not have.
//
// So it is written, under a name of its own. Not Bake_NNN, because a directory that looked like
// every other bake while holding sea level where three continents should be is a trap for whatever
// reads it next, and `tiles --bake` picks the newest Bake_NNN by default.
done := 0
for _, rr := range res.Regions {
if rr.Seconds > 0 {
done++
}
}
if done == 0 {
note = "cancelled before any region finished; nothing to write"
} else {
out = nextPartialDir(filepath.Dir(mPath))
if err := res.Write(out, 3000, s.bake.logf); err != nil {
fail(err)
return
}
note = fmt.Sprintf("cancelled after %d region(s); those are complete and written to %s. "+
"Everything else in it is still at sea level, which is why it is not a Bake_NNN", done, out)
}
} else {
out = planet.NextBakeDir(filepath.Dir(mPath))
if err := res.Write(out, 3000, s.bake.logf); err != nil {
fail(err)
return
}
s.writeBakePreview(res)
note = "wrote " + out
}
s.bake.mu.Lock()
s.bake.running, s.bake.finished = false, true
s.bake.outDir, s.bake.note = out, note
s.bake.stamp = time.Now().UnixNano()
s.bake.mu.Unlock()
}
// writeBakePreview draws the planet as it currently stands.
//
// The height and flow fields are *views* over the result's own arrays rather than copies: Painted() allocates
// three hundred megabytes, and doing that once a region while every worker is stopped is a cost with nothing
// to show for it. Safe because this only ever runs holding the composite lock.
func (s *Server) writeBakePreview(res *planet.Result) {
p := res.In.P
lo, hi := p.PadY*p.W, (p.H-p.PadY)*p.W
h := &field.Field{W: p.W, H: p.PaintH(), CellM: p.CellM, Data: res.Height.Data[lo:hi]}
flow := &field.Field{W: p.W, H: p.PaintH(), CellM: p.CellM, Data: res.Flow[lo:hi]}
// The painted sea, not the baked one: res.Sea is only computed once the whole run is over, and the
// painting already knows which cells are water.
sea := res.In.Map.Sea[lo:hi]
_, err := field.WritePreview(filepath.Join(s.planDir, bakePreviewName), h, field.PreviewOptions{
Flow: flow, Sea: sea, Snow: res.In.Map.SnowMask(), Palette: res.In.Palette,
SeaLevelM: res.In.M.SeaLevelM, RiverKm2: 0.5, Size: bakePreviewWidth,
})
if err != nil {
s.bake.logf("preview failed: %v", err)
}
}
// partialPrefix is deliberately outside the Bake_NNN namespace: latestBakeDir parses the suffix after
// "Bake_" as an integer, so this could never be mistaken for a finished bake even by accident, and a person
// reading the directory listing can see which is which without opening anything.
const partialPrefix = "Partial_"
func nextPartialDir(base string) string {
for n := 1; n < 10000; n++ {
dir := filepath.Join(base, fmt.Sprintf("%s%03d", partialPrefix, n))
if _, err := os.Stat(dir); os.IsNotExist(err) {
return dir
}
}
return filepath.Join(base, partialPrefix+"overflow")
}
+199
View File
@@ -0,0 +1,199 @@
package studio
import (
"encoding/json"
"fmt"
"math/rand/v2"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"salty/terrain/internal/overlay"
"salty/terrain/internal/planet"
)
// Generating the annotation layer from inside the studio.
//
// `terrain overlay` already does this from the command line, and the reason to have it here as well is that
// generation is not a step in a pipeline - it is a *draft*. An author presses it, looks at where the towns
// landed, presses it again, and keeps the third one. That loop only works where the sheet is already on
// screen and editable, which is here.
//
// Two things it does that the command does not, and both exist because a button is pressed repeatedly:
//
// - **Every press is a new seed.** The painting fixes where the land is; the seed decides everything it
// does not. So the button is a re-roll by construction, which is what makes looking at three drafts
// cheap.
// - **It replaces the last draft rather than piling on top of it.** The generator never overwrites a
// painted pixel, and after one press its own output *is* painted pixels - so a second press would
// generate around the first and the sheet would silt up. The server remembers exactly which pixels the
// last generation put down and clears those, and only those, before generating again. Hand-painted work
// is never in that set and so is never touched.
//
// It uses the newest bake when there is one and the painting alone when there is not, and says which. The
// difference is not cosmetic: without a solve there are no rivers to sit on and no slope to avoid, so the
// draft is a sketch.
// bakePrefix matches the command's. A directory is a bake if it is this plus an integer.
const bakePrefix = "Bake_"
// latestBake is the newest Bake_NNN beside the manifest, or "" when the planet has never been baked.
//
// Newest by *number* rather than by modification time: the numbers are the order the bakes were made, and a
// directory touched by a backup tool is not a newer bake.
func latestBake(base string) string {
entries, err := os.ReadDir(base)
if err != nil {
return ""
}
best, bestN := "", -1
for _, e := range entries {
if !e.IsDir() || !strings.HasPrefix(e.Name(), bakePrefix) {
continue
}
n, err := strconv.Atoi(strings.TrimPrefix(e.Name(), bakePrefix))
if err != nil || n <= bestN {
continue
}
// A directory that has no heightmap in it is a bake that was interrupted, and reading one would fail
// later with a worse message than simply not choosing it.
if _, err := os.Stat(filepath.Join(base, e.Name(), "planet_height.png")); err != nil {
continue
}
best, bestN = filepath.Join(base, e.Name()), n
}
return best
}
type genReply struct {
OK bool `json:"ok"`
Seed int64 `json:"seed"`
Bake string `json:"bake"`
FromBake bool `json:"from_bake"`
Lines []string `json:"lines"`
Marks []string `json:"marks"`
}
// handleOverlayGenerate fills the annotation sheet in from the world, and hands the page back a summary.
func (s *Server) handleOverlayGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST", http.StatusMethodNotAllowed)
return
}
var req struct {
Seed int64 `json:"seed"`
}
// An empty body is allowed: it means "pick a seed for me", which is what the button sends.
_ = json.NewDecoder(r.Body).Decode(&req)
s.mu.Lock()
defer s.mu.Unlock()
if s.ov == nil {
http.Error(w, "this planet has no overlay legend; set planet.overlay_legend first",
http.StatusNotFound)
return
}
wants := false
for i := range s.ov.Marks {
if s.ov.Marks[i].Generate != nil {
wants = true
break
}
}
if !wants {
http.Error(w, "no mark in the overlay legend has a `generate` block, so there is nothing to "+
"generate. Generation is opt-in per mark; see the overlay section of the templates README",
http.StatusBadRequest)
return
}
seed := req.Seed
if seed == 0 {
seed = int64(rand.Uint64()>>16) + 1
}
// The plan's prepare, which is where the class raster and the projected map come from. Reused when it is
// warm - the usual case, because an author plans before they look at anything - and built when it is not.
in := s.cache
if in == nil || s.cacheKey != s.planKey() {
var err error
in, err = planet.Prepare(s.m, func(string, ...any) {})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.cache, s.cacheKey = in, s.planKey()
}
// The sheet as it is on screen, with the last generation taken back out of it. Everything an author
// painted stays; everything the previous press put down goes, which is what makes this a re-roll rather
// than an accumulation.
existing := s.overlayRasterLocked()
cleared := 0
for i, m := range s.ovGen {
if m != overlay.Blank && i < len(existing.Mark) && existing.Mark[i] == m {
existing.Mark[i] = overlay.Blank
cleared++
}
}
// The newest bake, but only if it is a bake of *this* painting: two paintings of the same planet encode
// their heightmaps identically, so nothing else would catch it and the draft would be placed against
// terrain from another world. Decided here as well as inside the generator so that the line the page
// prints says which path actually ran.
bake := latestBake(filepath.Dir(s.manifestPath))
if bake != "" {
if ok, _ := planet.BakeIsOfThisPainting(bake, s.m); !ok {
bake = ""
}
}
var lines []string
log := func(format string, a ...any) { lines = append(lines, fmt.Sprintf(format, a...)) }
ras, rep, err := planet.GenerateOverlay(planet.OverlayGenOptions{
In: in, BakeDir: bake, Seed: seed, Existing: existing, Log: log,
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Back onto the sheet the author is looking at. The raster is the whole truth here - it already contains
// the pixels that were kept - so this is a straight encode rather than a merge.
px, alpha := s.ov.Encode(ras)
copy(s.ovPaint, px)
copy(s.ovAlpha, alpha)
s.ovDirty = true
// What this generation put down, so the next press can take it back out again. Only the cells that were
// blank before it ran: a mark sitting where the author painted one is theirs, not ours.
if s.ovGen == nil {
s.ovGen = make([]uint8, s.paintW*s.paintH)
}
for i := range s.ovGen {
if i < len(existing.Mark) && existing.Mark[i] == overlay.Blank && ras.Mark[i] != overlay.Blank {
s.ovGen[i] = ras.Mark[i]
} else {
s.ovGen[i] = overlay.Blank
}
}
reply := genReply{OK: true, Seed: seed, FromBake: bake != "", Lines: lines}
if bake != "" {
reply.Bake = filepath.Base(bake)
}
if cleared > 0 {
reply.Lines = append(reply.Lines, fmt.Sprintf("re-rolled: %d px of the last draft cleared first", cleared))
}
reply.Marks = planet.OverlaySummary(rep, ras.W, ras.H)
writeJSON(w, reply)
}
// overlayRasterLocked classifies the live sheet into marks. s.mu must be held.
func (s *Server) overlayRasterLocked() *overlay.Raster {
ras, _ := s.ov.Classify(s.ovPaint, s.ovAlpha, s.paintW, s.paintH)
return ras
}
File diff suppressed because it is too large Load Diff
+370
View File
@@ -0,0 +1,370 @@
package studio
import (
"fmt"
"strconv"
"strings"
)
// Editing a legend in place, without reformatting it.
//
// The obvious way to save a legend the studio has changed is to unmarshal it, set the fields and marshal it
// back. That destroys the file. A legend is mostly *commentary* - the `_comment_massif` block on `lowland` is
// four lines explaining why its floor is a tenth of its rate - and unmarshalling into the Class struct drops
// every underscore key on the floor. Unmarshalling into map[string]any keeps them and loses the order
// instead, because encoding/json sorts map keys, so the hand-laid table comes back alphabetised with every
// comment moved away from the thing it was commenting on.
//
// The same argument the palette writer already makes, one file over: this repository does not let
// MarshalIndent near a file a person wrote. So the studio patches the *text*. It finds the object for a named
// class and replaces one key's value inside it, or inserts the key if it is not there, and every byte it did
// not deliberately change comes out identical. That also means a legend edited here still diffs usefully,
// which for a file under review is most of the point.
// patchClassNumber sets one numeric key on one class, adding it if it is absent. The returned text is the
// input with exactly that value changed.
func patchClassNumber(src, class, key string, value float64) (string, error) {
start, end, err := classObject(src, class)
if err != nil {
return "", err
}
body := src[start:end]
num := formatNumber(value)
if ks, ke, ok := keyValue(body, key); ok {
return src[:start] + body[:ks] + fmt.Sprintf("%q: %s", key, num) + body[ke:] + src[end:], nil
}
// Not present: put it after the class's name, which is where a reader looks for it and which every class
// is guaranteed to have.
ns, ne, ok := keyValue(body, "name")
if !ok {
return "", fmt.Errorf("class %q has no name key to insert %q after", class, key)
}
_ = ns
ins := fmt.Sprintf(", %q: %s", key, num)
return src[:start] + body[:ne] + ins + body[ne:] + src[end:], nil
}
// patchClassRemove deletes one key from one class, taking its separating comma with it. Absent is not an
// error: the studio sends "this mark no longer says anything about the coast" whether or not it ever did.
func patchClassRemove(src, class, key string) (string, error) {
start, end, err := classObject(src, class)
if err != nil {
return "", err
}
body := src[start:end]
ks, ke, ok := keyValue(body, key)
if !ok {
return src, nil
}
// A key goes with exactly one of the two separators around it, and which one depends on where it sits.
// The test is a round trip: adding a key and taking it away again has to give the file back byte for
// byte, or every later diff carries the scar of a setting somebody tried once.
s, e := ks, ke
j := e
for j < len(body) && isJSONSpace(body[j]) {
j++
}
if j < len(body) && body[j] == ',' {
// Not the last key: take the comma after it, and the space that followed that comma in place of the
// one that preceded this key.
e = j + 1
if e < len(body) && body[e] == ' ' && s > 0 && body[s-1] == ' ' {
e++
}
} else {
// The last key in the object: there is no comma after it, so take the one before - and nothing
// forward, or the space in front of the closing brace goes with it.
for s > 0 && isJSONSpace(body[s-1]) {
s--
}
if s > 0 && body[s-1] == ',' {
s--
}
}
return src[:start] + body[:s] + body[e:] + src[end:], nil
}
func isJSONSpace(c byte) bool { return c == ' ' || c == '\n' || c == '\r' || c == '\t' }
// patchClassObject sets one object-valued key on one class - the massif block - or removes it when nil.
func patchClassObject(src, class, key string, fields map[string]float64, order []string) (string, error) {
start, end, err := classObject(src, class)
if err != nil {
return "", err
}
body := src[start:end]
var lit string
if fields != nil {
parts := make([]string, 0, len(order))
for _, k := range order {
v, ok := fields[k]
if !ok {
continue
}
parts = append(parts, fmt.Sprintf("%q: %s", k, formatNumber(v)))
}
lit = fmt.Sprintf("%q: { %s }", key, strings.Join(parts, ", "))
}
if ks, ke, ok := keyValue(body, key); ok {
if lit == "" {
// Removing it: take the separating comma with it, whichever side it is on.
s, e := ks, ke
for e < len(body) && (body[e] == ' ' || body[e] == '\n' || body[e] == '\r' || body[e] == '\t') {
e++
}
if e < len(body) && body[e] == ',' {
e++
} else {
for s > 0 && (body[s-1] == ' ' || body[s-1] == '\n' || body[s-1] == '\r' || body[s-1] == '\t') {
s--
}
if s > 0 && body[s-1] == ',' {
s--
}
}
return src[:start] + body[:s] + body[e:] + src[end:], nil
}
return src[:start] + body[:ks] + lit + body[ke:] + src[end:], nil
}
if lit == "" {
return src, nil // asked to remove something that is not there
}
// Inserted at the end of the class object, on a line of its own. Straight after the name would read
// better in a one-line class and reads badly in exactly the ones that matter: a class carrying
// commentary is written over several lines, and splicing into the middle of the first one leaves the
// rest of that line dangling behind the insertion.
brace := len(body) - 1
for brace > 0 && body[brace] != '}' {
brace--
}
head := strings.TrimRight(body[:brace], " \t\r\n")
return src[:start] + head + ",\n " + lit + "\n " + body[brace:] + src[end:], nil
}
// patchTopNumber sets a numeric key inside a named top-level object, such as the manifest's planet block.
func patchTopNumber(src, object, key string, value float64) (string, error) {
os, oe, err := objectAfterKey(src, object, 0)
if err != nil {
return "", err
}
body := src[os:oe]
num := formatNumber(value)
if ks, ke, ok := keyValue(body, key); ok {
return src[:os] + body[:ks] + fmt.Sprintf("%q: %s", key, num) + body[ke:] + src[oe:], nil
}
// Insert just inside the opening brace, on its own line.
return src[:os+1] + fmt.Sprintf("\n %q: %s,", key, num) + src[os+1:], nil
}
// patchTopString is patchTopNumber for a string value.
func patchTopString(src, object, key, value string) (string, error) {
os, oe, err := objectAfterKey(src, object, 0)
if err != nil {
return "", err
}
body := src[os:oe]
if ks, ke, ok := keyValue(body, key); ok {
return src[:os] + body[:ks] + fmt.Sprintf("%q: %q", key, value) + body[ke:] + src[oe:], nil
}
return src[:os+1] + fmt.Sprintf("\n %q: %q,", key, value) + src[os+1:], nil
}
// classObject is the byte range of the object in the classes array whose "name" is the one asked for,
// from its opening brace to just past its closing one.
func classObject(src, class string) (start, end int, err error) {
want := fmt.Sprintf("%q", class)
from := 0
for {
i := indexKeyValue(src, "name", want, from)
if i < 0 {
return 0, 0, fmt.Errorf("no class named %q in the legend", class)
}
// Walk back to the opening brace of the object this key sits in.
depth := 0
j := i
for ; j >= 0; j-- {
switch src[j] {
case '}':
depth++
case '{':
if depth == 0 {
s, e, ok := matchBrace(src, j)
if ok {
return s, e, nil
}
return 0, 0, fmt.Errorf("class %q: unbalanced braces", class)
}
depth--
}
}
from = i + 1
}
}
// objectAfterKey is the byte range of the object that is the value of the given key.
func objectAfterKey(src, key string, from int) (start, end int, err error) {
i := indexKey(src, key, from)
if i < 0 {
return 0, 0, fmt.Errorf("no %q object", key)
}
j := i
for j < len(src) && src[j] != '{' {
if src[j] == ',' || src[j] == '}' {
return 0, 0, fmt.Errorf("%q is not an object", key)
}
j++
}
if j >= len(src) {
return 0, 0, fmt.Errorf("%q is not an object", key)
}
s, e, ok := matchBrace(src, j)
if !ok {
return 0, 0, fmt.Errorf("%q: unbalanced braces", key)
}
return s, e, nil
}
// keyValue finds "key": value inside a body and returns the range covering both, value included.
func keyValue(body, key string) (start, end int, ok bool) {
i := indexKey(body, key, 0)
if i < 0 {
return 0, 0, false
}
// Past the colon, then over the value.
j := i
for j < len(body) && body[j] != ':' {
j++
}
j++
for j < len(body) && (body[j] == ' ' || body[j] == '\t' || body[j] == '\n' || body[j] == '\r') {
j++
}
if j >= len(body) {
return 0, 0, false
}
switch body[j] {
case '{':
_, e, ok := matchBrace(body, j)
if !ok {
return 0, 0, false
}
return i, e, true
case '[':
depth, k := 0, j
for ; k < len(body); k++ {
if body[k] == '[' {
depth++
} else if body[k] == ']' {
depth--
if depth == 0 {
return i, k + 1, true
}
}
}
return 0, 0, false
case '"':
k := j + 1
for ; k < len(body); k++ {
if body[k] == '\\' {
k++
continue
}
if body[k] == '"' {
return i, k + 1, true
}
}
return 0, 0, false
default:
k := j
for k < len(body) && body[k] != ',' && body[k] != '}' && body[k] != '\n' {
k++
}
// A bare number or keyword ends where the scan stopped, but the scan does not stop on a space, so
// `"width_m": 8 }` would otherwise hand back the space in front of the brace as part of the value -
// and every edit of a last-in-object key would quietly close it up to `8}`.
for k > j && isJSONSpace(body[k-1]) {
k--
}
return i, k, true
}
}
// indexKey finds the offset of a "key" token at object level, skipping any inside a string value.
func indexKey(s, key string, from int) int {
needle := fmt.Sprintf("%q", key)
for i := from; ; {
j := strings.Index(s[i:], needle)
if j < 0 {
return -1
}
at := i + j
// It is a key only if the next non-space character is a colon.
k := at + len(needle)
for k < len(s) && (s[k] == ' ' || s[k] == '\t') {
k++
}
if k < len(s) && s[k] == ':' {
return at
}
i = at + len(needle)
}
}
// indexKeyValue finds a "key": "value" pair and returns the offset of the key.
func indexKeyValue(s, key, quotedValue string, from int) int {
for i := from; ; {
at := indexKey(s, key, i)
if at < 0 {
return -1
}
_, e, ok := keyValue(s[at:], key)
if ok {
seg := strings.TrimSpace(s[at : at+e])
if strings.HasSuffix(seg, quotedValue) {
return at
}
}
i = at + 1
}
}
// matchBrace returns the range of the object opening at i.
func matchBrace(s string, i int) (start, end int, ok bool) {
depth := 0
inStr := false
for j := i; j < len(s); j++ {
c := s[j]
if inStr {
if c == '\\' {
j++
} else if c == '"' {
inStr = false
}
continue
}
switch c {
case '"':
inStr = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
return i, j + 1, true
}
}
}
return 0, 0, false
}
// formatNumber writes a number the way a person would: no exponent, no trailing zeros, and never bare "0."
func formatNumber(v float64) string {
s := strconv.FormatFloat(v, 'f', -1, 64)
if s == "-0" {
return "0"
}
return s
}
+231
View File
@@ -0,0 +1,231 @@
package studio
import (
"os"
"path/filepath"
"strings"
"testing"
)
const legendSrc = `{
"_comment": "what the colours mean",
"image": "Map3.jpg",
"classes": [
{ "name": "ocean", "rgb": [91, 175, 185], "sea": true, "depth_m": 512 },
{ "_comment_plain": "why the floor is a tenth of the rate, at length",
"name": "lowland", "rgb": [153, 204, 102], "uplift_mm_yr": 0.08, "k_mult": 1.0,
"massif": { "floor_mm_yr": 0.012, "fraction": 0.16 },
"coastal_plain_km": 1.0 },
{ "name": "highland", "rgb": [68, 170, 102], "uplift_mm_yr": 0.25, "k_mult": 1.0 }
]
}`
// The whole reason this is text surgery and not MarshalIndent: a legend is mostly commentary, and the
// commentary has to survive a save byte for byte.
func TestPatchingKeepsEverythingItDidNotChange(t *testing.T) {
out, err := patchClassNumber(legendSrc, "lowland", "uplift_mm_yr", 0.12)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"uplift_mm_yr": 0.12`) {
t.Error("the new value is not there")
}
if strings.Contains(out, `"uplift_mm_yr": 0.08`) {
t.Error("the old value is still there")
}
if !strings.Contains(out, `"_comment_plain": "why the floor is a tenth of the rate, at length"`) {
t.Error("the comment was dropped")
}
if !strings.Contains(out, `"uplift_mm_yr": 0.25`) {
t.Error("the other class's rate was touched")
}
// And nothing else moved: the only difference from the original is those four characters.
if a, b := strings.Replace(out, "0.12", "0.08", 1), legendSrc; a != b {
t.Errorf("the file changed somewhere else:\n--- got\n%s\n--- want\n%s", a, b)
}
}
func TestPatchingAddsAKeyThatIsNotThere(t *testing.T) {
out, err := patchClassNumber(legendSrc, "highland", "coastal_plain_km", 4)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"coastal_plain_km": 4`) {
t.Fatalf("the key was not added:\n%s", out)
}
if !strings.Contains(out, `"name": "highland", "coastal_plain_km": 4`) {
t.Errorf("it did not go in after the name:\n%s", out)
}
}
func TestPatchingAMassifBlock(t *testing.T) {
order := []string{"floor_mm_yr", "fraction"}
out, err := patchClassObject(legendSrc, "lowland", "massif",
map[string]float64{"floor_mm_yr": 0.02, "fraction": 0.25}, order)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"massif": { "floor_mm_yr": 0.02, "fraction": 0.25 }`) {
t.Fatalf("the massif block was not rewritten:\n%s", out)
}
// Adding one to a class that has none.
out, err = patchClassObject(legendSrc, "highland", "massif",
map[string]float64{"floor_mm_yr": 0.045, "fraction": 0.3}, order)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"massif": { "floor_mm_yr": 0.045, "fraction": 0.3 }`) {
t.Fatalf("the massif block was not added:\n%s", out)
}
}
func TestRemovingAMassifBlock(t *testing.T) {
out, err := patchClassObject(legendSrc, "lowland", "massif", nil, nil)
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "massif") {
t.Fatalf("the massif block is still there:\n%s", out)
}
// The class has to still parse: no doubled or dangling comma where it was.
if strings.Contains(out, ",,") || strings.Contains(out, ", }") && !strings.Contains(legendSrc, ", }") {
t.Errorf("the comma was left in a bad state:\n%s", out)
}
}
func TestPatchingARefusesAClassThatIsNotThere(t *testing.T) {
if _, err := patchClassNumber(legendSrc, "tundra", "uplift_mm_yr", 0.1); err == nil {
t.Fatal("it accepted a class the legend does not have")
}
}
const manifestSrc = `{
"level": "/Game/Maps/L_Planet",
"planet": {
"_comment_scale": "why the cell is eight metres",
"template": "Templates/Map3.jpg",
"circumference_km": 100,
"coast_jitter_px": 48
}
}`
func TestPatchingTheManifestPlanetBlock(t *testing.T) {
out, err := patchTopNumber(manifestSrc, "planet", "coast_jitter_px", 64)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"coast_jitter_px": 64`) {
t.Fatalf("not patched:\n%s", out)
}
if !strings.Contains(out, `"_comment_scale"`) {
t.Error("the comment was dropped")
}
out, err = patchTopNumber(manifestSrc, "planet", "massif_wavelength_km", 7)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"massif_wavelength_km": 7`) {
t.Fatalf("a missing key was not added:\n%s", out)
}
out, err = patchTopString(manifestSrc, "planet", "template", "Templates/Map3.png")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"template": "Templates/Map3.png"`) {
t.Fatalf("the template path was not repointed:\n%s", out)
}
}
// The base map is an input a person made by hand and there is no undo for it outside this process. Saving
// versions rather than overwriting is the whole contract, and the second half of it is that saving twice
// gives _001 and _002 rather than _001 and _001_002.
func TestSavingNeverOverwritesAndNumbersUpwards(t *testing.T) {
dir := t.TempDir()
base := filepath.Join(dir, "Map3.jpg")
if err := os.WriteFile(base, []byte("the base map"), 0o644); err != nil {
t.Fatal(err)
}
first, err := nextVersion(base)
if err != nil {
t.Fatal(err)
}
if filepath.Base(first) != "Map3_001.png" {
t.Errorf("first save went to %q, want Map3_001.png", filepath.Base(first))
}
if err := os.WriteFile(first, []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
// Asked again from the *versioned* path, which is what the manifest now points at.
second, err := nextVersion(first)
if err != nil {
t.Fatal(err)
}
if filepath.Base(second) != "Map3_002.png" {
t.Errorf("second save went to %q, want Map3_002.png", filepath.Base(second))
}
// And the base map is still exactly what it was.
if b, err := os.ReadFile(base); err != nil || string(b) != "the base map" {
t.Errorf("the base map was touched: %q %v", b, err)
}
}
// The overlay legend is patched by the same three functions - a mark is an object with a "name" like a class
// is - so the thing worth testing separately is the one that is new: removing a key.
const overlaySrc = `{
"image": "Map3.overlay.png",
"marks": [
{ "_comment": "why this shore is pinned, at length",
"name": "drawn_coast", "rgb": [255, 0, 255], "coast_jitter": 0 },
{ "name": "forest", "rgb": [0, 128, 0] },
{ "name": "road", "rgb": [90, 60, 30], "kind": "path", "width_m": 8 }
]
}`
func TestRemovingAKeyLeavesNoTrace(t *testing.T) {
// Add one, then take it away: the file has to come back exactly as it started, or every later diff
// carries the scar of a setting somebody tried once.
with, err := patchClassNumber(overlaySrc, "forest", "coast_jitter", 0.5)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(with, `"coast_jitter": 0.5`) {
t.Fatalf("the key was not added:\n%s", with)
}
back, err := patchClassRemove(with, "forest", "coast_jitter")
if err != nil {
t.Fatal(err)
}
if back != overlaySrc {
t.Errorf("a round trip changed the file:\n--- want ---\n%s\n--- got ---\n%s", overlaySrc, back)
}
// Removing one that is not there is not an error: the studio sends "this mark says nothing about the
// coast" whether or not it ever did.
same, err := patchClassRemove(overlaySrc, "road", "coast_jitter")
if err != nil || same != overlaySrc {
t.Errorf("removing an absent key should be a no-op; err=%v changed=%v", err, same != overlaySrc)
}
// And the one that is there, on a mark carrying commentary.
out, err := patchClassRemove(overlaySrc, "drawn_coast", "coast_jitter")
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "coast_jitter") {
t.Error("the key is still there")
}
if !strings.Contains(out, `"_comment": "why this shore is pinned, at length"`) {
t.Error("the comment went with it")
}
if !strings.Contains(out, `"name": "drawn_coast", "rgb": [255, 0, 255] }`) {
t.Errorf("the trailing comma and its space were not cleaned up:\n%s", out)
}
}
+303
View File
@@ -0,0 +1,303 @@
package studio
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/png"
"io"
"net/http"
"os"
"path/filepath"
"salty/terrain/internal/plates"
"salty/terrain/internal/template"
)
// The tectonic layer in the studio: a third sheet beside the geology and the annotation.
//
// It is held, served and saved exactly as the other two are, and the one thing worth writing down is why it
// is resampled on the way in.
//
// A tectonic layer does not have to be the template's size. plates.FromPainting registers it by *extent*, and
// the layer `terrain plan --propose-plates` writes is a few thousand pixels wide because a plate is tens of
// kilometres across and nothing downstream reads finer than the 250 m tectonic grid. The studio's canvas, its
// brush, its undo and its tile upload all assume every sheet is the template's size, though - D-61's whole
// design rests on one geometry shared by every layer - and generalising them to three resolutions would be a
// great deal of code for a picture of seven blobs. So the layer is upsampled to the template's size on the
// way in and saved at that size. It costs nothing on disk: it is a handful of flat colours, and PNG stores
// that in a few kilobytes however large the canvas is.
//
// **A blank tectonic layer is not empty, it is one plate.** The overlay starts transparent because most of an
// annotation is nothing; here every pixel is some piece of lithosphere, so a sheet that has never been
// painted starts as the legend's first plate all over. That is the class template's rule rather than the
// overlay's, and it is the same rule plates.nearestPlate follows when it refuses to leave a pixel unassigned.
// loadPlates reads the tectonic layer, or starts a blank one the right size.
func (s *Server) loadPlates() error {
if s.m.PlatesLegendPath() == "" {
return nil
}
lg, err := plates.LoadPaintLegend(s.m.PlatesLegendPath())
if err != nil {
return err
}
s.pl = lg
if path := s.platesImagePath(); path != "" {
if _, statErr := os.Stat(path); statErr == nil {
px, w, h, err := template.DecodeRGB(path)
if err != nil {
return err
}
s.plPaint = resampleRGB(px, w, h, s.paintW, s.paintH)
s.plOnDisk = true
return nil
}
}
s.plPaint = blankPlates(lg, s.paintW*s.paintH)
return nil
}
// blankPlates is a sheet of the legend's first plate: see the note above about a blank layer being one plate
// rather than nothing.
func blankPlates(lg *plates.PaintLegend, cells int) []uint8 {
out := make([]uint8, cells*3)
if len(lg.Plates) == 0 {
return out
}
c := lg.Plates[0].RGB
for i := 0; i < cells; i++ {
out[i*3], out[i*3+1], out[i*3+2] = uint8(c[0]), uint8(c[1]), uint8(c[2])
}
return out
}
// resampleRGB scales a sheet to a new size by nearest neighbour.
//
// Nearest, never interpolated. Every pixel of this layer is a plate id wearing a colour, and a blend of two
// plate colours is a third plate as far as nearestPlate is concerned - so a bilinear resample would paint a
// one-pixel ribbon of some unrelated plate down every margin on the planet.
func resampleRGB(src []uint8, sw, sh, dw, dh int) []uint8 {
if sw == dw && sh == dh {
out := make([]uint8, len(src))
copy(out, src)
return out
}
out := make([]uint8, dw*dh*3)
for y := 0; y < dh; y++ {
sy := y * sh / dh
for x := 0; x < dw; x++ {
sx := x * sw / dw
s := (sy*sw + sx) * 3
d := (y*dw + x) * 3
out[d], out[d+1], out[d+2] = src[s], src[s+1], src[s+2]
}
}
return out
}
// platesImagePath is the layer the manifest names, or the one its legend names beside itself.
func (s *Server) platesImagePath() string {
if p := s.m.PlatesLayerPath(); p != "" {
return p
}
if s.pl != nil && s.pl.Image != "" {
return filepath.Join(filepath.Dir(s.m.PlatesLegendPath()), s.pl.Image)
}
return ""
}
func (s *Server) handlePlatesPNG(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
if s.pl == nil {
s.mu.Unlock()
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
return
}
img := rgbaFrom(s.plPaint, s.paintW, s.paintH)
s.mu.Unlock()
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
_ = (&png.Encoder{CompressionLevel: png.BestSpeed}).Encode(w, img)
}
// rgbaFrom turns an RGB sheet into an opaque image ready to encode.
func rgbaFrom(px []uint8, w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for i, n := 0, w*h; i < n; i++ {
img.Pix[i*4] = px[i*3]
img.Pix[i*4+1] = px[i*3+1]
img.Pix[i*4+2] = px[i*3+2]
img.Pix[i*4+3] = 255
}
return img
}
// handlePlatesPost takes the browser's tectonic canvas back, and saves it when asked.
func (s *Server) handlePlatesPost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
img, err := png.Decode(bytes.NewReader(body))
if err != nil {
http.Error(w, "the body is not a PNG: "+err.Error(), http.StatusBadRequest)
return
}
b := img.Bounds()
s.mu.Lock()
defer s.mu.Unlock()
if s.pl == nil {
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
return
}
if b.Dx() != s.paintW || b.Dy() != s.paintH {
http.Error(w, fmt.Sprintf("the tectonic canvas is %dx%d and the template is %dx%d",
b.Dx(), b.Dy(), s.paintW, s.paintH), http.StatusBadRequest)
return
}
changed := false
for y := 0; y < s.paintH; y++ {
for x := 0; x < s.paintW; x++ {
cr, cg, cb, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
o := (y*s.paintW + x) * 3
r8, g8, b8 := uint8(cr>>8), uint8(cg>>8), uint8(cb>>8)
if s.plPaint[o] != r8 || s.plPaint[o+1] != g8 || s.plPaint[o+2] != b8 {
changed = true
}
s.plPaint[o], s.plPaint[o+1], s.plPaint[o+2] = r8, g8, b8
}
}
if changed {
s.plSeq++
s.plDirty = true
}
if r.URL.Query().Get("save") == "1" {
path, repointed, err := s.savePlates()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.plDirty, s.plOnDisk = false, true
writeJSON(w, map[string]any{"saved": path, "repointed": repointed})
return
}
writeJSON(w, map[string]any{"ok": true})
}
// savePlates writes the layer to the next free numbered PNG and points the manifest at it. Same rule as the
// other two sheets and for the same reason: it never overwrites, because there is no undo for a painting
// outside this process. A layer that has never existed is written at the name already asked for, which is not
// an overwrite because nothing is there.
func (s *Server) savePlates() (path string, repointed bool, err error) {
src := s.platesImagePath()
if src == "" {
return "", false, fmt.Errorf("%s names no tectonic layer and its legend names none either; set "+
"planet.plates.layer or the legend's \"image\"", s.manifestPath)
}
if _, statErr := os.Stat(src); os.IsNotExist(statErr) {
path = src
} else {
if path, err = nextVersion(src); err != nil {
return "", false, err
}
}
var buf bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestCompression}
if err := enc.Encode(&buf, rgbaFrom(s.plPaint, s.paintW, s.paintH)); err != nil {
return "", false, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", false, err
}
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
return "", false, err
}
rel := filepath.ToSlash(filepath.Join(filepath.Dir(s.m.Planet.Plates.Legend), filepath.Base(path)))
if rel != s.m.Planet.Plates.Layer {
if err := s.patchManifest(func(text string) (string, error) {
return patchTopString(text, "plates", "layer", rel)
}); err != nil {
return path, false, err
}
repointed = true
}
return path, repointed, nil
}
// plateEdit is one plate's motion as the page sends it back.
type plateEdit struct {
Plate string `json:"plate"`
SpeedCmYr *float64 `json:"speed_cm_yr"`
HeadingDeg *float64 `json:"heading_deg"`
SpinDegMyr *float64 `json:"spin_deg_myr"`
}
// handlePlatesLegend writes the tectonic legend by patching its text, the same way the class and overlay
// legends are written: the commentary at the top of that file is the only place the heading convention is
// written down, and marshalling the struct back would delete it.
func (s *Server) handlePlatesLegend(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
serveJSONFile(w, s.m.PlatesLegendPath())
return
}
if r.Method != http.MethodPost {
http.Error(w, "POST", http.StatusMethodNotAllowed)
return
}
var edits []plateEdit
if err := json.NewDecoder(r.Body).Decode(&edits); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
path := s.m.PlatesLegendPath()
if path == "" || s.pl == nil {
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
return
}
text, err := os.ReadFile(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
out := string(text)
for _, e := range edits {
for key, v := range map[string]*float64{
"speed_cm_yr": e.SpeedCmYr,
"heading_deg": e.HeadingDeg,
"spin_deg_myr": e.SpinDegMyr,
} {
if v == nil {
continue
}
if out, err = patchClassNumber(out, e.Plate, key, *v); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
}
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.reload(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"saved": path})
}
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
package studio
import (
"net/http"
"os"
)
// Sharing the studio's files with another tool on the same machine.
//
// World Orogen (Tools/Orogen, D-66) reads the same painting and the same legends this studio edits, and until
// now the only way to get them there was a file picker: choose the PNG, choose the legend, choose the overlay,
// choose its legend, retype the manifest's numbers. The studio already holds every one of those - the painting
// in memory, exactly as the next plan will read it - so it serves them, and Orogen loads a planet with one
// button.
//
// Two rules keep this from turning the studio into something a web page can drive:
//
// - **Only GET is shared.** The CORS header goes on GET responses and nothing else, and no preflight is ever
// answered. A cross-origin POST with a JSON body needs a preflight, so every endpoint that paints, saves,
// plans or bakes stays reachable from this page and from nothing else. The studio listens on loopback, but
// a browser on the same machine visits other origins all day, and "any tab can start a two-hour bake" is
// not a property to give away for a convenience.
// - **The files are served as they are on disk.** The legend and the manifest are text somebody wrote, with
// commentary; Orogen reads the same keys the plan does and ignores the rest. Nothing is re-marshalled, so
// what Orogen sees is byte for byte what `terrain plan` will see.
// readOnlyCORS lets any origin *read* the API and touches nothing else.
func readOnlyCORS(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
h.ServeHTTP(w, r)
})
}
// serveJSONFile answers a GET for one of the planet's JSON files, or says there is none. The POST handlers
// call it first and return, so an endpoint that edits a file also hands the file out.
func serveJSONFile(w http.ResponseWriter, path string) {
if path == "" {
http.Error(w, "the manifest names no such file", http.StatusNotFound)
return
}
data, err := os.ReadFile(path)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
}
@@ -0,0 +1,73 @@
package studio
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// The one property share.go promises: another origin can read, and cannot do anything else.
func TestCORSIsReadOnly(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
h := readOnlyCORS(inner)
for _, tc := range []struct {
method string
want string
}{
{http.MethodGet, "*"},
{http.MethodHead, "*"},
{http.MethodPost, ""},
{http.MethodOptions, ""},
{http.MethodDelete, ""},
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(tc.method, "/api/legend", nil))
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != tc.want {
t.Errorf("%s: Access-Control-Allow-Origin = %q, want %q", tc.method, got, tc.want)
}
// No preflight is answered: a browser needs Allow-Methods to send a cross-origin POST, and it never
// gets one.
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "" {
t.Errorf("%s: Access-Control-Allow-Methods = %q, want none", tc.method, got)
}
}
}
func TestServeJSONFileIsTheFileOnDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "legend.json")
// Commentary and formatting are the point: what Orogen reads is the text the author wrote.
text := "{\n \"_comment\": \"kept\",\n \"classes\": []\n}\n"
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
serveJSONFile(rec, path)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
if got := rec.Body.String(); got != text {
t.Errorf("body changed:\n%s\nwant\n%s", got, text)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type %q", ct)
}
// A planet with no overlay legend has "" for its path, and that is a 404 rather than a read of "".
rec = httptest.NewRecorder()
serveJSONFile(rec, "")
if rec.Code != http.StatusNotFound {
t.Errorf("empty path: status %d, want 404", rec.Code)
}
rec = httptest.NewRecorder()
serveJSONFile(rec, filepath.Join(dir, "missing.json"))
if rec.Code != http.StatusNotFound {
t.Errorf("missing file: status %d, want 404", rec.Code)
}
}