Tooling
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user