Knight docs
EN ID
GitHub
← All plugins

JSON

Parse, read, build and save JSON documents from a gamemode.

Kind
Go
Status
bundled, compiled in
Natives
JsonParse JsonParseFile JsonStringify JsonFree JsonGetString JsonGetInt JsonGetFloat JsonGetBool JsonHas JsonType JsonCount JsonKeys JsonEscape JsonSaveFile
Hooks
player::connect

Configuration

VariableDefault
KNIGHT_JSON_ROOTscriptfiles

Enable

Add the path to server.plugins (or `plugins` in server.cfg).

knight
server.plugins = ["plugins/json"];

Workflow

flowchart TD
  A["Gamemode .sa"] -->|JsonParse| B["Parse document"]
  B --> C["Store in memory<br/>(document id)"]
  C -->|JsonGetString / JsonGetInt| D["Read values by dot path"]
  C -->|JsonStringify| E["Serialise back to text"]
  C -->|JsonSaveFile| F["Write inside scriptfiles"]
  F -->|JsonParseFile| B
  C -->|JsonFree| G["Release document"]

Source

plugins/json/json.go 504 lines
go
// Package json is a Knight plugin that exposes JSON parsing and building to
// gamemodes.
//
// It demonstrates the plugin extension point with a self-contained feature:
//
//   - natives callable from .sa (JsonParse, JsonGetString, JsonGetInt, ...)
//   - a hook on player::connect that loads a per-player JSON file
//
// Activate it by blank-importing the package from the server build:
//
//	import _ "knight/plugins/json"
package json

import (
	"encoding/json"
	"errors"
	"math"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"

	"knight/pkg/plugin"
)

// Config tunes the plugin. Zero values fall back to the defaults below.
type Config struct {
	// Root is the directory used by the file natives. Default "scriptfiles".
	Root string
	// MaxBytes caps the size of a file the natives will read. Default 1 MiB.
	MaxBytes int64
}

const (
	defaultRoot     = "scriptfiles"
	defaultMaxBytes = 1 << 20
)

type jsonPlugin struct {
	api plugin.API
	cfg Config

	mu   sync.Mutex
	docs map[string]any // documents held between call (id -> parsed value)
	next int
}

func init() { plugin.Register(func() plugin.Plugin { return &jsonPlugin{} }) }

func (p *jsonPlugin) Info() plugin.Info {
	return plugin.Info{Name: "json", Version: "1.0.0", Author: "Knight"}
}

func (p *jsonPlugin) Init(api plugin.API) error {
	p.api = api
	if p.cfg.Root == "" {
		// KNIGHT_JSON_ROOT lets a deployment point the plugin at an existing
		// scriptfiles directory without rebuilding.
		p.cfg.Root = os.Getenv("KNIGHT_JSON_ROOT")
		if p.cfg.Root == "" {
			p.cfg.Root = defaultRoot
		}
	}
	if p.cfg.MaxBytes <= 0 {
		p.cfg.MaxBytes = defaultMaxBytes
	}
	if p.docs == nil {
		p.docs = map[string]any{}
	}

	api.RegisterNative("JsonParse", p.parse)
	api.RegisterNative("JsonParseFile", p.parseFile)
	api.RegisterNative("JsonFree", p.free)
	api.RegisterNative("JsonStringify", p.stringify)
	api.RegisterNative("JsonGetString", p.getString)
	api.RegisterNative("JsonGetInt", p.getInt)
	api.RegisterNative("JsonGetFloat", p.getFloat)
	api.RegisterNative("JsonGetBool", p.getBool)
	api.RegisterNative("JsonHas", p.has)
	api.RegisterNative("JsonType", p.typeOf)
	api.RegisterNative("JsonCount", p.count)
	api.RegisterNative("JsonKeys", p.keys)
	api.RegisterNative("JsonEscape", p.escape)
	api.RegisterNative("JsonSaveFile", p.saveFile)

	api.Hook("player::connect", plugin.PriorityNormal, p.onConnect)
	return nil
}

func (p *jsonPlugin) Shutdown() {
	p.mu.Lock()
	p.docs = map[string]any{}
	p.mu.Unlock()
}

// onConnect loads <root>/players/<name>.json when it exists, so a gamemode can
// read saved profile data with JsonParseFile or the cached id.
func (p *jsonPlugin) onConnect(ctx plugin.Ctx) bool {
	name, ok := ctx.PlayerName(int(ctx.Int(0)))
	if !ok || name == "" {
		return true
	}
	path := filepath.Join(p.cfg.Root, "players", safeName(name)+".json")
	if _, err := os.Stat(path); err == nil {
		ctx.Log("json: profile available for %s", name)
	}
	return true
}

// --- helpers ---

func safeName(name string) string {
	var b strings.Builder
	for _, r := range name {
		switch {
		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-', r == '.':
			b.WriteRune(r)
		default:
			b.WriteByte('_')
		}
	}
	return b.String()
}

// keyPath splits "a.b.0.c" into ["a","b","0","c"].
func keyPath(path string) []string {
	if path == "" {
		return nil
	}
	return strings.Split(path, ".")
}

func lookup(doc any, path string) (any, bool) {
	cur := doc
	for _, key := range keyPath(path) {
		switch node := cur.(type) {
		case map[string]any:
			v, ok := node[key]
			if !ok {
				return nil, false
			}
			cur = v
		case []any:
			i, err := strconv.Atoi(key)
			if err != nil || i < 0 || i >= len(node) {
				return nil, false
			}
			cur = node[i]
		default:
			return nil, false
		}
	}
	return cur, true
}

func (p *jsonPlugin) store(v any) string {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.next++
	id := "doc" + strconv.Itoa(p.next)
	p.docs[id] = v
	return id
}

func (p *jsonPlugin) load(id string) (any, bool) {
	p.mu.Lock()
	defer p.mu.Unlock()
	v, ok := p.docs[id]
	return v, ok
}

func arg(args []plugin.Value, i int) string {
	if i < len(args) {
		return args[i].Str
	}
	return ""
}

func (p *jsonPlugin) readFile(rel string) ([]byte, error) {
	path := rel
	if !filepath.IsAbs(path) {
		path = filepath.Join(p.cfg.Root, path)
	}
	clean := filepath.Clean(path)
	root, err := filepath.Abs(p.cfg.Root)
	if err == nil {
		abs, aerr := filepath.Abs(clean)
		if aerr == nil && !strings.HasPrefix(abs, root) {
			return nil, errors.New("json: path escapes the root directory")
		}
	}
	st, err := os.Stat(clean)
	if err != nil {
		return nil, err
	}
	if st.Size() > p.cfg.MaxBytes {
		return nil, errors.New("json: file too large")
	}
	return os.ReadFile(clean)
}

// --- natives ---

// JsonParse(text) -> id or "" on error.
func (p *jsonPlugin) parse(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	var v any
	if err := json.Unmarshal([]byte(arg(args, 0)), &v); err != nil {
		ctx.Log("json: parse error: %v", err)
		return plugin.StrV("")
	}
	return plugin.StrV(p.store(v))
}

// JsonParseFile(path) -> id or "" on error.
func (p *jsonPlugin) parseFile(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	data, err := p.readFile(arg(args, 0))
	if err != nil {
		ctx.Log("json: %v", err)
		return plugin.StrV("")
	}
	var v any
	if err := json.Unmarshal(data, &v); err != nil {
		ctx.Log("json: parse %s: %v", arg(args, 0), err)
		return plugin.StrV("")
	}
	return plugin.StrV(p.store(v))
}

// JsonFree(id) -> bool.
func (p *jsonPlugin) free(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	p.mu.Lock()
	defer p.mu.Unlock()
	id := arg(args, 0)
	_, ok := p.docs[id]
	delete(p.docs, id)
	return plugin.BoolV(ok)
}

// JsonStringify(id, indent) -> string ("" when the id is unknown).
func (p *jsonPlugin) stringify(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	doc, ok := p.load(arg(args, 0))
	if !ok {
		return plugin.StrV("")
	}
	indent := ""
	if len(args) > 1 && args[1].Int > 0 {
		indent = strings.Repeat(" ", int(args[1].Int))
	}
	var out []byte
	var err error
	if indent == "" {
		out, err = json.Marshal(doc)
	} else {
		out, err = json.MarshalIndent(doc, "", indent)
	}
	if err != nil {
		return plugin.StrV("")
	}
	return plugin.StrV(string(out))
}

// JsonGetString(id, path) -> string.
func (p *jsonPlugin) getString(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	doc, ok := p.load(arg(args, 0))
	if !ok {
		return plugin.StrV("")
	}
	v, ok := lookup(doc, arg(args, 1))
	if !ok {
		return plugin.StrV("")
	}
	switch node := v.(type) {
	case string:
		return plugin.StrV(node)
	case float64:
		return plugin.StrV(strconv.FormatFloat(node, 'f', -1, 64))
	case bool:
		return plugin.StrV(strconv.FormatBool(node))
	case nil:
		return plugin.StrV("")
	}
	raw, err := json.Marshal(v)
	if err != nil {
		return plugin.StrV("")
	}
	return plugin.StrV(string(raw))
}

func (p *jsonPlugin) getInt(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	doc, ok := p.load(arg(args, 0))
	if !ok {
		return plugin.IntV(0)
	}
	v, ok := lookup(doc, arg(args, 1))
	if !ok {
		return plugin.IntV(0)
	}
	switch node := v.(type) {
	case float64:
		return plugin.IntV(int64(node))
	case string:
		n, _ := strconv.ParseInt(node, 10, 64)
		return plugin.IntV(n)
	case bool:
		if node {
			return plugin.IntV(1)
		}
	}
	return plugin.IntV(0)
}

func (p *jsonPlugin) getFloat(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	doc, ok := p.load(arg(args, 0))
	if !ok {
		return plugin.FloatV(0)
	}
	v, ok := lookup(doc, arg(args, 1))
	if !ok {
		return plugin.FloatV(0)
	}
	switch node := v.(type) {
	case float64:
		return plugin.FloatV(node)
	case string:
		f, _ := strconv.ParseFloat(node, 64)
		return plugin.FloatV(f)
	}
	return plugin.FloatV(0)
}

func (p *jsonPlugin) getBool(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	doc, ok := p.load(arg(args, 0))
	if !ok {
		return plugin.BoolV(false)
	}
	v, ok := lookup(doc, arg(args, 1))
	if !ok {
		return plugin.BoolV(false)
	}
	switch node := v.(type) {
	case bool:
		return plugin.BoolV(node)
	case float64:
		return plugin.BoolV(node != 0 && !math.IsNaN(node))
	case string:
		return plugin.BoolV(node != "" && node != "false" && node != "0")
	}
	return plugin.BoolV(false)
}

func (p *jsonPlugin) has(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	doc, ok := p.load(arg(args, 0))
	if !ok {
		return plugin.BoolV(false)
	}
	_, found := lookup(doc, arg(args, 1))
	return plugin.BoolV(found)
}

func (p *jsonPlugin) typeOf(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	doc, ok := p.load(arg(args, 0))
	if !ok {
		return plugin.StrV("missing")
	}
	v, found := lookup(doc, arg(args, 1))
	if !found {
		return plugin.StrV("missing")
	}
	switch v.(type) {
	case nil:
		return plugin.StrV("null")
	case bool:
		return plugin.StrV("bool")
	case float64:
		return plugin.StrV("number")
	case string:
		return plugin.StrV("string")
	case []any:
		return plugin.StrV("array")
	case map[string]any:
		return plugin.StrV("object")
	}
	return plugin.StrV("unknown")
}

// JsonCount(id) -> top-level element count (array/object), 0 otherwise.
func (p *jsonPlugin) count(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	doc, ok := p.load(arg(args, 0))
	if !ok {
		return plugin.IntV(0)
	}
	v := doc
	if len(args) > 1 && arg(args, 1) != "" {
		child, found := lookup(doc, arg(args, 1))
		if !found {
			return plugin.IntV(0)
		}
		v = child
	}
	switch node := v.(type) {
	case []any:
		return plugin.IntV(int64(len(node)))
	case map[string]any:
		return plugin.IntV(int64(len(node)))
	case string:
		return plugin.IntV(int64(len(node)))
	}
	return plugin.IntV(0)
}

// JsonKeys(id, path) -> comma-separated keys (object) or indexes (array).
func (p *jsonPlugin) keys(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	doc, ok := p.load(arg(args, 0))
	if !ok {
		return plugin.StrV("")
	}
	v := doc
	if len(args) > 1 && arg(args, 1) != "" {
		child, found := lookup(doc, arg(args, 1))
		if !found {
			return plugin.StrV("")
		}
		v = child
	}
	switch node := v.(type) {
	case map[string]any:
		keys := make([]string, 0, len(node))
		for k := range node {
			keys = append(keys, k)
		}
		sortStrings(keys)
		return plugin.StrV(strings.Join(keys, ","))
	case []any:
		idx := make([]string, len(node))
		for i := range node {
			idx[i] = strconv.Itoa(i)
		}
		return plugin.StrV(strings.Join(idx, ","))
	}
	return plugin.StrV("")
}

// JsonEscape(text) -> JSON string body with quotes.
func (p *jsonPlugin) escape(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	out, err := json.Marshal(arg(args, 0))
	if err != nil {
		return plugin.StrV("\"\"")
	}
	return plugin.StrV(string(out))
}

// JsonSaveFile(id, path, indent) -> bool.
func (p *jsonPlugin) saveFile(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
	doc, ok := p.load(arg(args, 0))
	if !ok {
		return plugin.BoolV(false)
	}
	path := arg(args, 1)
	if path == "" {
		return plugin.BoolV(false)
	}
	if !filepath.IsAbs(path) {
		path = filepath.Join(p.cfg.Root, path)
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		ctx.Log("json: save %s: %v", path, err)
		return plugin.BoolV(false)
	}
	indent := ""
	if len(args) > 2 && args[2].Int > 0 {
		indent = strings.Repeat(" ", int(args[2].Int))
	}
	var data []byte
	var err error
	if indent == "" {
		data, err = json.Marshal(doc)
	} else {
		data, err = json.MarshalIndent(doc, "", indent)
	}
	if err != nil {
		return plugin.BoolV(false)
	}
	if err := os.WriteFile(path, data, 0o644); err != nil {
		ctx.Log("json: save %s: %v", path, err)
		return plugin.BoolV(false)
	}
	return plugin.BoolV(true)
}

func sortStrings(s []string) {
	for i := 1; i < len(s); i++ {
		for j := i; j > 0 && s[j] < s[j-1]; j-- {
			s[j], s[j-1] = s[j-1], s[j]
		}
	}
}

// SetConfig tunes the plugin before Init (used by tests and embedders).
func (p *jsonPlugin) SetConfig(c Config) { p.cfg = c }

// New returns a plugin instance with the given configuration.
func New(c Config) plugin.Plugin { return &jsonPlugin{cfg: c} }