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