506 lines
12 KiB
Go
506 lines
12 KiB
Go
package playground
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing/fstest"
|
|
"unicode/utf8"
|
|
|
|
"aego/app/assembly"
|
|
playmods "aego/app/assembly/playground"
|
|
"aego/core/log"
|
|
"aego/engine"
|
|
"aego/engine/docs"
|
|
"aego/engine/module"
|
|
"aego/modules/render2d"
|
|
scriptsystems "aego/modules/scripting/systems"
|
|
"aego/platform/input"
|
|
"aego/scene"
|
|
"aego/scene/scriptcomp"
|
|
"aego/script/bind"
|
|
"aego/script/bind/manifest"
|
|
"aego/script/bytecode"
|
|
"aego/script/diag"
|
|
"aego/script/langsvc"
|
|
"aego/script/lexer"
|
|
"aego/script/runtime"
|
|
"aego/script/source"
|
|
"aego/script/workspace"
|
|
)
|
|
|
|
//go:embed console/donut.aes
|
|
var donutSource string
|
|
|
|
const (
|
|
Module = "game"
|
|
ScriptsDir = "scripts"
|
|
DefaultFrames = 120
|
|
MaxFrames = 3600
|
|
)
|
|
|
|
type Diagnostic struct {
|
|
File string `json:"file"`
|
|
Line int `json:"line"`
|
|
Col int `json:"col"`
|
|
EndLine int `json:"endLine"`
|
|
EndCol int `json:"endCol"`
|
|
Code string `json:"code"`
|
|
Severity string `json:"severity"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type Line struct {
|
|
Frame uint64 `json:"frame"`
|
|
Level string `json:"level"`
|
|
Channel string `json:"channel"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type Fault struct {
|
|
Script string `json:"script"`
|
|
Node string `json:"node"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
File string `json:"file,omitempty"`
|
|
Line int `json:"line,omitempty"`
|
|
}
|
|
|
|
type Result struct {
|
|
Diagnostics []Diagnostic `json:"diagnostics"`
|
|
Log []Line `json:"log"`
|
|
Faults []Fault `json:"faults"`
|
|
Frames int `json:"frames"`
|
|
}
|
|
|
|
type Token struct {
|
|
Start int `json:"s"`
|
|
End int `json:"e"`
|
|
Kind string `json:"k"`
|
|
}
|
|
|
|
type Example struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Code string `json:"code"`
|
|
Scene bool `json:"scene,omitempty"`
|
|
}
|
|
|
|
type Playground struct {
|
|
types *scene.Types
|
|
reg *bind.Registry
|
|
man *module.Manifest
|
|
sections *module.Sections
|
|
docs *docs.Registry
|
|
ws *workspace.Workspace
|
|
proj *exampleProject
|
|
gameCfg GameConfig
|
|
game *Game
|
|
}
|
|
|
|
func New() (*Playground, error) {
|
|
proj, err := loadProject()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
types, err := assembly.NewTypes()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
reg := bind.New()
|
|
man, err := playmods.Catalog().Load(types, reg, assembly.Options{Seed: 1, Testing: true})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sections, err := man.LoadSections(proj.block, coreBlocks...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := types.Schema().Freeze(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := scriptsystems.Freeze(reg, types.Schema()); err != nil {
|
|
return nil, err
|
|
}
|
|
bm, err := manifest.FromRegistry(reg, types.Schema())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d := docs.FromManifest(bm, types.Schema(), man)
|
|
bm.Keywords = d.Keywords()
|
|
ws, err := workspace.Open(workspace.Config{
|
|
FS: fstest.MapFS{},
|
|
Module: Module,
|
|
Scripts: ScriptsDir,
|
|
Bindings: reg.Table(),
|
|
Docs: bm,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Playground{types: types, reg: reg, man: man, sections: sections, docs: d, ws: ws, proj: proj}, nil
|
|
}
|
|
|
|
func (p *Playground) Docs() *docs.Registry {
|
|
return p.docs
|
|
}
|
|
|
|
func packageOf(src []byte) string {
|
|
toks, _, _ := lexer.TokenizeAll(1, src, source.NewInterner())
|
|
for i, t := range toks {
|
|
if t.Kind.IsKeyword() && t.Text == "package" && i+1 < len(toks) && toks[i+1].Text != "" {
|
|
return toks[i+1].Text
|
|
}
|
|
}
|
|
return "main"
|
|
}
|
|
|
|
func place(files map[string]string) (map[string][]byte, error) {
|
|
out := make(map[string][]byte, len(files))
|
|
for name, src := range files {
|
|
p := path.Clean("/" + name)[1:]
|
|
if p == "" {
|
|
p = "main" + workspace.Ext
|
|
}
|
|
if path.Ext(p) != workspace.Ext {
|
|
p += workspace.Ext
|
|
}
|
|
if path.Dir(p) == "." {
|
|
p = path.Join(packageOf([]byte(src)), p)
|
|
}
|
|
out[path.Join(ScriptsDir, p)] = []byte(src)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, errors.New("no script files")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func severity(d diag.Diagnostic) string {
|
|
switch d.Severity {
|
|
case diag.Error:
|
|
return "error"
|
|
case diag.Info:
|
|
return "info"
|
|
}
|
|
return "warning"
|
|
}
|
|
|
|
func diagnostics(sm *source.Manager, ds []diag.Diagnostic) []Diagnostic {
|
|
out := make([]Diagnostic, 0, len(ds))
|
|
for _, d := range ds {
|
|
x := Diagnostic{Code: d.Code, Message: d.Message, Severity: severity(d)}
|
|
if d.Primary.Valid() {
|
|
x.File = strings.TrimPrefix(sm.Path(d.Primary.File), ScriptsDir+"/")
|
|
x.Line, x.Col = sm.LineCol(d.Primary.File, d.Primary.Start)
|
|
x.EndLine, x.EndCol = sm.LineCol(d.Primary.File, d.Primary.End)
|
|
}
|
|
out = append(out, x)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func failed(err error) []Diagnostic {
|
|
return []Diagnostic{{Severity: "error", Message: err.Error()}}
|
|
}
|
|
|
|
func (p *Playground) Compile(files map[string]string) []Diagnostic {
|
|
placed, err := place(files)
|
|
if err != nil {
|
|
return failed(err)
|
|
}
|
|
p.ws.Sync(placed)
|
|
snap := p.ws.Snapshot()
|
|
return diagnostics(snap.Sources, snap.Diagnostics)
|
|
}
|
|
|
|
func record(frame *uint64, r log.Record) Line {
|
|
var b strings.Builder
|
|
b.WriteString(r.Msg)
|
|
for _, f := range r.Fields {
|
|
if f.Key == "stack" || f.Key == "go" {
|
|
continue
|
|
}
|
|
b.WriteString(" " + f.Key + "=" + fmt.Sprint(f.Val))
|
|
}
|
|
return Line{Frame: *frame, Level: r.Level.String(), Channel: string(r.Channel), Text: b.String()}
|
|
}
|
|
|
|
func shortName(typ string) string {
|
|
return typ[strings.LastIndexByte(typ, '.')+1:]
|
|
}
|
|
|
|
func (p *Playground) spawn(rt *runtime.Runtime) *scene.Scene {
|
|
s := scene.New(p.types, nil, scene.Options{})
|
|
for _, typ := range rt.Linked() {
|
|
h := s.Create(s.Root(), shortName(typ))
|
|
*scene.Add[render2d.SpriteRenderer](s, h) = render2d.DefaultSpriteRenderer()
|
|
sc := scene.Add[scriptcomp.ScriptComponent](s, h)
|
|
sc.Attachments = append(sc.Attachments, scriptcomp.Attachment{TypeName: typ, Enabled: true})
|
|
}
|
|
s.Flush()
|
|
s.UpdateTransforms()
|
|
return s
|
|
}
|
|
|
|
func (p *Playground) Run(files map[string]string, frames int) Result {
|
|
switch {
|
|
case frames <= 0:
|
|
frames = DefaultFrames
|
|
case frames > MaxFrames:
|
|
frames = MaxFrames
|
|
}
|
|
placed, err := place(files)
|
|
if err != nil {
|
|
return Result{Diagnostics: failed(err)}
|
|
}
|
|
p.ws.Sync(placed)
|
|
gen, ds := p.ws.Build()
|
|
res := Result{Diagnostics: diagnostics(gen.Sources, ds)}
|
|
if len(gen.Failed) > 0 || diag.HasErrors(ds) {
|
|
return res
|
|
}
|
|
pkgs := make([]string, 0, len(gen.Modules))
|
|
for pkg := range gen.Modules {
|
|
pkgs = append(pkgs, pkg)
|
|
}
|
|
sort.Strings(pkgs)
|
|
mods := make([]*bytecode.Module, len(pkgs))
|
|
for i, pkg := range pkgs {
|
|
mods[i] = gen.Modules[pkg]
|
|
}
|
|
var frame uint64
|
|
lg := log.New(log.FuncSink(func(r log.Record) {
|
|
if strings.HasPrefix(r.Msg, "script stopped: ") {
|
|
return
|
|
}
|
|
res.Log = append(res.Log, record(&frame, r))
|
|
}))
|
|
lg.SetLevel(log.Debug)
|
|
rt := runtime.New(runtime.Options{Bindings: p.reg, Schema: p.types.Schema(), Log: lg, VM: playmods.SandboxVM()})
|
|
defer rt.Close()
|
|
if err := rt.Link(mods); err != nil {
|
|
res.Diagnostics = append(res.Diagnostics, failed(err)...)
|
|
return res
|
|
}
|
|
s := p.spawn(rt)
|
|
if err := rt.Instantiate(s); err != nil {
|
|
res.Diagnostics = append(res.Diagnostics, failed(err)...)
|
|
return res
|
|
}
|
|
w := engine.NewWorld("playground")
|
|
w.Input = &input.State{}
|
|
ctx := engine.Context{World: w, Dt: 1.0 / 60}
|
|
for i := 0; i < frames; i++ {
|
|
ctx.Frame++
|
|
frame = ctx.Frame
|
|
rt.RunDeferred()
|
|
rt.FixedUpdate(&ctx)
|
|
rt.Update(&ctx)
|
|
rt.Coroutines(&ctx)
|
|
s.Flush()
|
|
s.UpdateTransforms()
|
|
}
|
|
res.Frames = frames
|
|
for _, f := range rt.Faults() {
|
|
x := Fault{Script: f.Type, Node: f.Path, Code: f.Fault.Code, Message: f.Fault.Message}
|
|
if len(f.Fault.Stack) > 0 {
|
|
x.File = strings.TrimPrefix(f.Fault.Stack[0].File, ScriptsDir+"/")
|
|
x.Line = int(f.Fault.Stack[0].Line)
|
|
}
|
|
res.Faults = append(res.Faults, x)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func utf16Offsets(src []byte) []int {
|
|
out := make([]int, len(src)+1)
|
|
u := 0
|
|
for i := 0; i < len(src); {
|
|
r, n := utf8.DecodeRune(src[i:])
|
|
for k := 0; k < n; k++ {
|
|
out[i+k] = u
|
|
}
|
|
if r >= 0x10000 {
|
|
u += 2
|
|
} else {
|
|
u++
|
|
}
|
|
i += n
|
|
}
|
|
out[len(src)] = u
|
|
return out
|
|
}
|
|
|
|
func tokenKind(src []byte, start int, keyword bool) string {
|
|
c := src[start]
|
|
switch {
|
|
case keyword:
|
|
return "keyword"
|
|
case c == '"' || c == '`':
|
|
return "string"
|
|
case c >= '0' && c <= '9':
|
|
return "number"
|
|
case c == '@':
|
|
return "attr"
|
|
case c == '_' || c >= 'a' && c <= 'z':
|
|
return "ident"
|
|
case c >= 'A' && c <= 'Z':
|
|
return "type"
|
|
}
|
|
return "punct"
|
|
}
|
|
|
|
func Tokens(text string) []Token {
|
|
src := []byte(text)
|
|
toks, comments, _ := lexer.TokenizeAll(1, src, source.NewInterner())
|
|
off := utf16Offsets(src)
|
|
out := make([]Token, 0, len(toks)+len(comments))
|
|
for _, t := range toks {
|
|
s, e := int(t.Start), int(t.End)
|
|
if e <= s || e > len(src) || t.Text == "" {
|
|
continue
|
|
}
|
|
out = append(out, Token{Start: off[s], End: off[e], Kind: tokenKind(src, s, t.Kind.IsKeyword())})
|
|
}
|
|
for _, c := range comments {
|
|
s, e := int(c.Start), min(int(c.End), len(src))
|
|
out = append(out, Token{Start: off[s], End: off[e], Kind: "comment"})
|
|
}
|
|
sort.Slice(out, func(a, b int) bool { return out[a].Start < out[b].Start })
|
|
return out
|
|
}
|
|
|
|
func (p *Playground) Stubs() map[string]string {
|
|
snap := p.ws.Snapshot()
|
|
out := map[string]string{}
|
|
for _, sp := range langsvc.StubPaths(snap) {
|
|
if st, ok := langsvc.StubFor(snap, sp); ok {
|
|
out[sp] = st.Text
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *Playground) Examples() []Example {
|
|
out := make([]Example, 0, len(p.proj.scenes)+8)
|
|
for _, s := range p.proj.scenes {
|
|
out = append(out, Example{ID: ScenePrefix + s.name, Title: s.title, Code: s.files[s.main], Scene: true})
|
|
}
|
|
out = append(out, Example{ID: "console/donut", Title: "Software 3D donut (console)", Code: donutSource})
|
|
for _, e := range p.docs.All() {
|
|
n := 0
|
|
for _, s := range e.Snippets {
|
|
if !s.Run || s.Lang != "aes" {
|
|
continue
|
|
}
|
|
n++
|
|
title := e.Title
|
|
if n > 1 {
|
|
title += " " + strconv.Itoa(n)
|
|
}
|
|
out = append(out, Example{ID: e.ID + "/" + strconv.Itoa(n), Title: title, Code: s.Code})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type request struct {
|
|
Files map[string]string `json:"files"`
|
|
Frames int `json:"frames"`
|
|
Source string `json:"source"`
|
|
Example string `json:"example"`
|
|
Count int `json:"count"`
|
|
Animate bool `json:"animate"`
|
|
Lose bool `json:"lose"`
|
|
}
|
|
|
|
func (p *Playground) Call(method string, payload []byte) ([]byte, error) {
|
|
var req request
|
|
if len(payload) > 0 {
|
|
if err := json.Unmarshal(payload, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
var out any
|
|
switch method {
|
|
case "compile":
|
|
out = p.Compile(req.Files)
|
|
case "run":
|
|
p.Stop()
|
|
out = p.Run(req.Files, req.Frames)
|
|
case "tokens":
|
|
out = Tokens(req.Source)
|
|
case "stubs":
|
|
out = p.Stubs()
|
|
case "examples":
|
|
out = p.Examples()
|
|
case "play":
|
|
out = p.Play(req.Example, req.Files)
|
|
case "stop":
|
|
p.Stop()
|
|
out = true
|
|
case "poll":
|
|
out = p.Poll()
|
|
case "bench":
|
|
out = p.Bench(req.Count, req.Animate)
|
|
case "loseContext":
|
|
out = p.LoseContext(req.Lose)
|
|
default:
|
|
return nil, errors.New("unknown method " + strconv.Quote(method))
|
|
}
|
|
return json.Marshal(out)
|
|
}
|
|
|
|
func (p *Playground) Reply(method, payload string) (reply string) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
reply = errorReply(fmt.Sprint("playground crashed: ", r))
|
|
}
|
|
}()
|
|
out, err := p.Call(method, []byte(payload))
|
|
if err != nil {
|
|
return errorReply(err.Error())
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func errorReply(msg string) string {
|
|
b, _ := json.Marshal(map[string]string{"error": msg})
|
|
return string(b)
|
|
}
|
|
|
|
func Format(res Result) []string {
|
|
var out []string
|
|
for _, d := range res.Diagnostics {
|
|
if d.Severity == "error" {
|
|
out = append(out, strconv.Itoa(d.Line)+":"+strconv.Itoa(d.Col)+" "+d.Code+" "+d.Message)
|
|
}
|
|
}
|
|
if len(out) > 0 {
|
|
return out
|
|
}
|
|
for _, l := range res.Log {
|
|
out = append(out, "["+strconv.FormatUint(l.Frame, 10)+"] "+l.Text)
|
|
}
|
|
for _, f := range res.Faults {
|
|
out = append(out, f.Script+" stopped with "+f.Code+": "+f.Message)
|
|
}
|
|
return append(out, strconv.Itoa(res.Frames)+" frames")
|
|
}
|
|
|
|
func (p *Playground) TryScenes() []docs.Entry {
|
|
var out []docs.Entry
|
|
for _, e := range p.docs.All() {
|
|
if e.Try != "" {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|