ZacharyWesterman / paisley

Plasma Automation / Instruction Scripting Language
GNU General Public License v3.0
0 stars 0 forks source link

Paisley

PAISLey (Plasma Automation / Instruction Scripting Language) is a scripting language designed to allow for easy command-based behavior scripting. The purpose of this language is NOT to be fast. Instead, Paisley is designed to be a simple, light-weight way to chain together more complex behavior (think NPC logic, device automation, etc.). This language is meant to make complex device logic very easy to implement, while also being as easy to learn as possible.

FAQ

Q: Why not use Lua instead?
A: The biggest advantage Paisley has over Lua nodes is that the script does not require any "pausing" logic that one would have to implement in order to get the same functionality in Lua; the Paisley runtime will automatically pause execution periodically to avoid Lua timeouts or performance drops, and will always wait on results from a command before continuing execution.

Q: Why not use sketch nodes instead?
A: Paisley was designed as a language for scripting NPC behavior, and the thing about NPCs is that their behavior needs to be able to change in response to various events. First off, when using just sketch nodes, dynamically changing an NPC's programming is downright impossible. Second, connecting all the nodes necessary for every possible event is difficult and time consuming (for example NPC chatter, a sequence of movements, events that only happen ONCE, etc). TL;DR: NPC logic is difficult to implement using just sketch nodes. Trust me, I've done it.

Q: How do I connect the Paisley engine to my device?
A: Get the Paisley Engine device from the Steam workshop (ID 3087775427), and attach it to your device. Then in a different controller, set the inputs (Paisley code, a list of valid commands for this device, and optional file name), and make sure the "Run Command" output will eventually flow back into the "Command Return" input. Keep in mind this MUST have a slight delay (0.02s at least) or Plasma will detect it as an infinite loop!

Q: Why???
A: haha

Q: Isn't writing an entire compiler in Plasma a bit overkill?
A: Your face is overkill. Compilers are fun, fight me.


Main program structures

As a general rule, white space and line endings do not matter in Paisley. The only use of line endings is to separate commands, which can also be done with a semicolon ; character.

A Paisley script may consist of a series of comments, statements, and/or commands.

Before continuing, note that commands do not have to be hard-coded. You can put expressions in them, such as

let r = 500
print "r = {r}, d = {3.14 * r * r}"

See how in the above, expressions are contained inside curly braces, {}. More on that later.

Conditionals:

"If" statements have the following structure:

if {expression is truey} then
    ...
elif {expression is truey} then
    ...
else
    ...
end

You can also leave out the "then" clause if all that's needed is the "else" clause, e.g.:

if {expression is truey} else
    ... do this if expression is falsey ...
end

Note that, unlike Lua's elseif keyword, the appropriate "else if" keyword in Paisley is elif. Also keep in mind that if statements convert the expression to a boolean, and so use a few rules to test an expression's trueness: false, null, zero, and empty strings, arrays and objects are falsey, everything else is truey.

There is also the match structure, which is similar to c-like languages' switch/case structure (or Rust's match). This structure is included to allow for more readable logic with less repeated code.

match {expression} do
    if {case 1} then ... end
    if {case 2} then ... end
    ...
else
    ... default action if no cases match ...
end

For example:

match {random_int(1,5)} do
    if 1 then print one end
    if 2 then print two end
else
    print "some other number"
end

Of course, like if statements, the else branch is optional and can be excluded.

Loops:

While and For loops have a similar syntax to Lua:

while {expression is truey} do
    ...
end

for value in {expression} do
    ...
end

for key value in {pairs(object or array)} do
    ...
end

These are the only loop structures possible. Note that the middle loop type will iterate over all values in an array, and all keys in an object!

If you want syntax similar to Lua's integer for loops (for i = 1, 10 do ... end), you can use something like for i in {1:10} do ... end. If you want an infinite loop, just use something like while 1 do ... end or while {true} do ... end.

Variable Assignment:

Variable assignment always starts with let, e.g.

let pi = 3.14
let circumference = {2 * pi * r}

Note that the let keyword is required even when reassigning variables. For example, consider the following:

let var = 13
var = 99

The second line will not set var's value to 13. Instead, that would attempt to run a command called "var" with the parameters ["=", "99"].

Of course, sometimes a variable will contain an array that you don't want to overwrite, instead you just want to update a single element or append to the array. The following will result in var containing the array (1, 2, 123, 4, 99). Note that giving negative values as the index will start counting from the end, so index of -1 will update the last element.

let var = 1 2 3 4 5
let var{3} = 123
let var{-1} = 99

Appending is just as simple. The following will result in var containing the array (1, 2, 3, 4, 5, 6).

let var = 1 2 3 4 5
let var{} = 6

You can also assign multiple variables at the same time.

let a b c = 1 2 3

#Alternatively, you can assign variables from values in an array
let list = {1:9}
let a b c = {list}

To simply define a variable as null, you can just leave off the expression. The following all initialize variables as null.

let var
let a b c
let foo = {null}

REMEMBER: All variables are global, so any "re-definition" of a variable just sets it to the new value.

Variable Initialization:

There is a special keyword for setting a variable's value if it hasn't been assigned already.

initial variable = {value}

Unlike the let keyword, initial can only define one variable, and it cannot insert or update sub-elements in arrays. The use of initial is instead a concise way to set a default value for un-initialized variables. Logically, it is identical to the following:

if {not (variable exists)} then
    let variable = {value}
end

Subroutines:

Proper use of subroutines can let you easily reuse common code.

Subroutines are basically user-defined functions that are called as if they were commands. Like commands, they can take parameters and optionally return a value, but they don't have to. Unlike commands, they can modify global variables, which may or may not be desired. Just keep it in mind when writing subroutines.

An example subroutine usage might look like the following:

subroutine print_numbers
    for i in {0 : @[1]} do
        if {i > 30} then
            print "whoa, too big!"
            return
        end
        print {i}
    end
end

gosub print_numbers 10
gosub print_numbers 50

subroutine power
    return {@[1] ^ @[2]}
end

print ${gosub power 2 10}

See how in the above, the @ variable stores any parameters passed to a subroutine as an array, so the first parameter is @[1], the second is @[2] and so on. For constant indexes, the square brackets are optional, e.g. @1 and @2 will also work, but not @ 2. Also see that subroutines return values the same way that commands do, using the inline command evaluation syntax, ${...}.

Note that it is also possible to jump to subroutines with an arbitrary label ID. See how in the following example, the program will randomly call one of 5 possible subroutines, and then print "Subroutine exists".

if gosub "{random_int(1,5)}" then
    print "Subroutine exists"
end

subroutine 1 end
subroutine 2 end
subroutine 3 end
subroutine 4 end
subroutine 5 end

Subroutine Memoization:

Some subroutines may take a very long time to compute values, when we only really need them to be computed once for any given input. For these kinds of subroutines, the cache keyword can be used to memoize the subroutine and only compute the results once. See the following recursive fibonacci example:

cache subroutine fib
    if {@1 < 2} then return {@1} end
    return {
        ${gosub fib {@1 - 1}} +
        ${gosub fib {@1 - 2}}
    }
end

Subsequent calls to fib will be very fast, because each fibonacci number only has to be computed once.

If it turns out that you need to invalidate a specific subroutine's cache, you can manually do so:

break cache fib

If the subroutine is not memoized, this of course does nothing.

Lambdas:

Lambdas are another good way to reuse code, however unlike subroutines, these are specifically for reusing parts of expressions. Lambdas are defined with the syntax ![expression], and are referred to with that same ! identifier, just without the brackets. Note that the ! can be any number of exclamation marks, optionally followed by an alphanumeric identifier. So for example, !!, !2, and !!lambda_1 are all valid lambda identifiers, all referring to different lambdas. Note that unlike lambdas in other languages, lambdas in Paisley are not true functions; they don't take any parameters. However, they do have access to the same global scope as the rest of the program.

Below is an example of lambda usage. Both the top and bottom commands will print 5 random numbers in the range 0-100.

print {![random_int(0, 100)], !, !, !, !}

#do the same thing, but using the define keyword
define {!rnd[random_int(0, 100)]}
print {!rnd, !rnd, !rnd, !rnd, !rnd}

Note that either of the above commands are equivalent to the following:

print {random_int(0, 100), random_int(0, 100), random_int(0, 100), random_int(0, 100), random_int(0, 100)}

Unlike variables, lambdas are restricted to their scope. Thus, for example, if you define a lambda in a subroutine, you cannot use it outside the subroutine, unless that outside scope also has a lambda definition with the same identifier.

Other statements:

Expressions:

First and foremost, expressions will only be evaluated inside curly braces, {}. If you place an expression outside of braces, it will be treated as plain text. For example print {1+2} will print "3" but print 1+2 will print the actual string "1+2".

Expressions can be placed anywhere inside a command or statement operand. In addition, they can also be placed inside double-quoted strings (e.g. "a = {1+2}" gives a = 3) to perform easy string interpolation. Note that single-quoted strings do not interpolate expressions, so for example 'a = {1+2}' would give exactly a = {1+2} without parsing any expression.

If you would like to avoid interpolation in double-quoted strings, simply escape the opening curly brace with a backslash, e.g.

print "the expression \{1+2} evaluates to {1+2}"
print "you can also put \"quotes\" and line breaks (\n) inside strings!"

There are a few special escape sequences:

Expressions also give access to a full suite of operators and functions, listed below:

Operators:

An extra note on slices: when slicing an array or a string, it's possible to replace the second number with a colon, to indicate that the slice should go from the start index all the way to the end of the string or array. So for example, "abcdef"[4::] would result in "def", (5,4,3,2,1)[2::] would result in (4,3,2,1), etc.

Allowed values:

Built-in functions:

Note that functions can be called in one of two ways:

  1. The usual syntax, e.g. split(var, delim)
  2. Using dot-notation, e.g. var.split(delim)

Both are exactly equivalent, the latter syntax is included simply for convenience.

Arrays in expressions

The comma , and slice : operators always indicate an array. For example, (1,2,3) is an array, as are (1,2,3,), (1,) and (,). Note that expressions are allowed to have a trailing comma, which simply indicates that the expression is an array with a single element. Likewise, a single comma by itself indicates an empty array.

Note that an expression must contain a comma , or slice : operator to be considered an array, just parentheses is not enough. So (1,) is an array and (1:1) is an equivalent array, but (1) is a number, not an array.

Basically, if there's a comma, you have an array.

To access an array's elements, use the usual square-brackets syntax, e.g.

let array = {'a', 'b', 'c'}
print {array[1]} #prints "a"

And to change an array's elements,

let array{2} = 'd' #array is now ('a', 'd', 'c')
let array{-1} = 'e' #array is now ('a', 'd', 'e')

You can also append to an array,

let array{} = 'f' #array is now ('a', 'd', 'e', 'f')

Note that array indexes start at 1, and can be negative to start from the end of the array instead of the beginning (e.g. -1 is the last element, -2 is the second to last, etc).

Objects in expressions

Objects function very much like JavaScript objects. The keys are always strings, and the values can be anything. To define an object, just construct a list of key-value pairs, which are any two expressions separated by an arrow, e.g. "key" => "value". Note that unlike in Lua, you cannot mix array and object syntax; it's either one or the other.

Like with arrays, key-value pairs are allowed to have an optional trailing comma.

let object = {
    'name' => 'Jerry',
    'age' => 30,
    'friend' => (
        'name' => 'Susan',
        'age' => 35,
    ),
}

In some cases, it can be useful to create an empty object. To do so, just use the arrow operator by itself.

let object = {=>}
print {'my object is "' (=>) '"'}

Object values can of course be accessed the same way array values can, with the regular indexing [] syntax. However, attributes can also be accessed with dot notation. The following lines do the exact same thing.

print {object['name']}
print {object.name}

Like with arrays, object values can be added or changed with the following syntax,

let object{'name'} = 'Jekyll'
let object{'friend', 'name'} = 'Hyde'

However, you cannot use the append syntax on an object, as it does not make sense in that context. So the following will not work:

let object{} = 'some value'

List comprehension

Often you need to take an array and mutate every element in some way. While you could very well use a for loop for this, this operation comes up often enough that there is a convenient shorthand for it. See how in the following script, we're taking the array x and multiplying every element by 2, then assigning the result to y.

let x = {1,2,3}
let y = {,}
for i in {x} do
    let y = {append(y, x * 2)}
end

The above could be written much more succinctly as the following:

let x = {1,2,3}
let y = {i * 2 for i in x}

Those of you familiar with Python will realize where the syntax comes from, and like in Python, you can filter out array elements based on a condition. See how in the following script, x is all numbers from 1 to 100, and we're selecting only those numbers divisible by 5, and storing the result in y.

let x = {1:100}
let y = {,}
for i in {x} do
    if {i % 5 = 0} then
        let y = {append(y, x)}
    end
end

The above could instead be written as the following:

let x = {1:100}
let y = {i for i in x if i % 5 = 0}

Inline Command Evaluation

Since commands can return values to Paisley after execution, you can also use those values in further calculations. For example:

#Get an integer value representing in-game time, and convert it to a human-readable format
let t = {floor(${time})}
let hour = {t // 3600}
let minute = {(t // 60) % 60}
let second = {t % 60}
print {hour ":" minute ":" second}

Of course, there is also a simpler version that does the same thing:

print {${time}.clocktime()[1:3].join(":")}

Built-in commands

For ease of use and consistency, there are 6 built-in commands that will always be the same regardless of device.

Note that all commands take a little bit of time to run (at least 0.02s), whether they're built-in or not. This is to prevent "infinite loop" errors or performance drops.


Lastly, to give an idea of the syntax, here is an example program that will create a clock that stays in sync with the client system.

#Repeat forever. "while 1 do" would also work.
while {true} do
    #Format date as YYYY-MM-DD
    let date = {${sysdate}.reverse().join('-')}

    #Format time as HH:MM:SS
    #Note the lpad() use makes sure that hours/minutes/seconds are always 2 digits
    let time = {(i.lpad('0', 2) for i in ${systime}.clocktime()[1:3]).join(':')}
    print {date ' @ ' time}

    #Only update once per second
    sleep 1
end