312 lines
11 KiB
Go
312 lines
11 KiB
Go
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")
|
|
}
|