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") }