gameshell/gamesequence.py

357 lines
13 KiB
Python

# gamesequence.py
"""This contains the functions and classes necessary to parse and execute the scripting language.
Classes:
- SequenceError: Derived from RuntimeError.
- ScriptBreak: Represent a 'break' statement.
- ScriptEnvironment: Represent the environment of a script.
Functions:
- getValueFromString: Translate a literal or variable to a value.
- compareValues: compare values for a conditional statement.
- ifScript: Execute an 'if' statement.
- getCustomValue: Get the value of a global variable.
- setCustomValue: Set the value of a global variable.
- delCustomValue: Delete a global variable.
- runScript: Execute a script.
- parseScript: Parse a script.
"""
import re as _re
class SequenceError(RuntimeError):
"""Derived from RuntimeError.
Raise whenever there is a runtime execution problem while parsing or executing a script.
"""
pass
class ScriptBreak(object):
"""Class created when a 'break' statement is read."""
def __init__(self, value):
"""Create a script break object with the value returned from the script broken from."""
self.value = value
class ScriptEnvironment(object):
"""Represent the environment of a script.
This contains a dictionary of all global variables, as well as local variables."""
def __init__(self, globalVars: dict, localVars: dict):
"""Create a script environment.
Global vars will survive beyond the end of the script.
Local vars are all dropped at the end of a script."""
if not isinstance(globalVars, dict):
raise TypeError("Global variables must be in a dictionary.")
if not isinstance(globalVars, dict):
raise TypeError("Local variables must be in a dictionary.")
self.globalVars = globalVars
self.localVars = localVars
def getValueFromString(arg: str, env: ScriptEnvironment):
"""Translate a literal or variable name into a value.
arg should be a string representing a literal or identifier.
env should be a ScriptEnvironment.
Return the value described by the first literal or variable name."""
val = None
# We test for a valid identifier here, before all the if-elif starts.
validIdent = _re.match(r'[_A-Za-z][_0-9A-Za-z]*', arg)
if arg[0] in '"\'' and arg[-1] == arg[0]:
# The argument is a string literal.
val = arg[1:-1]
elif _re.match(r'[+-]?(?:[0-9]*[.])?[0-9]+', arg) != None:
# The argument is a number.
if '.' in arg:
# The argument is a float.
val = float(arg)
else:
# The argument is an int.
val = int(arg)
elif arg.casefold() == 'true':
# The argument is the boolean value 'true'.
val = True
elif arg.casefold() == 'false':
# The argument is the boolean value 'false'.
val = False
elif validIdent != None and env != None:
# The argument is a variable name.
group = validIdent.group()
if group in env.localVars:
# The variable is local.
val = env.localVars[group]
elif group in env.globalVars:
# The variable is global.
val = env.globalVars[group]
else:
return False # for if statements; if a variable doesn't exist, should evaluate to False
# evaluate all values of all indecies
openBracket = validIdent.end()
if openBracket < len(arg):
if arg[openBracket] == '[':
ptr = openBracket
depth = 0
while ptr < len(arg):
if depth == 0 and arg[ptr] != '[':
raise SequenceError('Invalid value syntax: {}'.format(arg))
if arg[ptr] == '[':
depth += 1
elif arg[ptr] == ']':
depth -= 1
if depth == 0:
index = getValueFromString(arg[openBracket+1:ptr], env)
if index in val:
val = val[index]
else:
return False
openBracket = ptr + 1
ptr += 1
if depth != 0:
#depth should always == 0 at this point
raise SequenceError('Mismatched number of open and close brackets: {}'.format(arg))
else:
raise SequenceError('Invalid value syntax: {}'.format(arg))
else:
raise SequenceError('Invalid argument to getValueFromString: {}'.format(arg))
return val
def compareValues(args: list, env: ScriptEnvironment):
"""Generalized comparisons, may eventually be extended to other operators"""
if len(args) == 1:
return bool(getValueFromString(args[0], env))
elif len(args) == 3 and args[1] in ('==', '!=', '<=', '>=', '<', '>', 'in'):
lval = getValueFromString(args[0], env)
operator = args[1]
rval = getValueFromString(args[2], env)
if operator == '==':
return lval == rval
elif operator == '!=':
return lval != rval
elif operator == '<=':
return lval <= rval
elif operator == '>=':
return lval >= rval
elif operator == '<':
return lval < rval
elif operator == '>':
return lval > rval
elif operator == 'in':
return lval in rval
else:
raise SequenceError("Condition cannot be evaluated: {}".format(' '.join(args)))
def ifScript(args, env: ScriptEnvironment, externalScripts: dict):
"""If statement: if [not] value [op value] script"""
if len(args) < 2:
raise GameError('Incomplete If statement: if {}'.format(' '.join(args)))
inverse = False
ret = False
if args[0] == 'not':
inverse = True
args.pop(0)
if len(args) < 2:
raise GameError('Incomplete If statement: if {}'.format(' '.join(args)))
# evaluate condition
if len(args) > 1 and args[1] in ('==', '!=', '<=', '>=', '<', '>', 'in'):
if len(args) < 4:
raise SequenceError('Incomplete If statement: if {}'.format(' '.join(args)))
ret = compareValues(args[:3], env)
args = args[3:]
else:
ret = bool(getValueFromString(args[0], env))
args = args[1:]
if inverse:
ret = not ret
# if condition is true, evaluate further
if ret:
if isinstance(args[-1], list):
return runScript(args[-1], env, externalScripts)
else:
return runScript([args], env, externalScripts)
def getCustomValue(args, env: dict):
if env == None:
raise SequenceError("Cannot get a value from an empty environment.")
return getValueFromString(args[0], env)
def setCustomValue(args, env: dict):
"""takes [customValue, op, value]"""
if env == None:
raise SequenceError("Cannot set a value from an empty environment.")
scope = env.localVars
if len(args) > 0 and args[0] == 'global':
scope = env.globalVars
args.pop(0)
if len(args) < 3 or args[1] not in ('=', '+=', '-=', '*=', '/=', '%=', '//=', '**=', 'b=', '!=', '|=', '&=', '^='):
raise SequenceError('Arguments are not fit for the setCustomValue script.')
# This next line allows cvmod to use values in the thing's customValues dict,
# but doing this means that accessing a game CV requires setting it to a local var.
val = getValueFromString(args[2], env)
# set the value to a default value (0, 0.0, '', etc.) if not yet assigned
if args[0] not in scope and args[1] != '=':
if isinstance(val, int):
scope[args[0]] = 0
elif isinstance(val, float):
scope[args[0]] = 0.0
elif isinstance(val, str):
scope[args[0]] = ''
elif isinstance(val, bool):
scope[args[0]] = False
# Beyond this point are types that can only be found from other customValues
elif isinstance(val, tuple):
scope[args[0]] = ()
elif isinstance(val, list):
scope[args[0]] = []
elif isinstance(val, dict):
scope[args[0]] = {}
else:
raise SequenceError("Operation not supported for unassigned custom values of type {}: {}"
.format(type(val), args[1]))
# done parsing, evaluate
if args[1] == '=':
scope[args[0]] = val
elif args[1] == '+=':
scope[args[0]] += val
elif args[1] == '-=':
scope[args[0]] -= val
elif args[1] == '*=':
scope[args[0]] *= val
elif args[1] == '/=':
scope[args[0]] /= val
elif args[1] == '%=':
scope[args[0]] %= val
elif args[1] == '//=':
scope[args[0]] //= val
elif args[1] == '**=':
scope[args[0]] **= val
elif args[1] == 'b=':
scope[args[0]] = bool(val)
elif args[1] == '!=':
scope[args[0]] = not bool(val)
elif args[1] == '|=':
scope[args[0]] |= val
elif args[1] == '&=':
scope[args[0]] &= val
elif args[1] == '^=':
scope[args[0]] ^= val
return scope[args[0]]
def delCustomValue(args, env: ScriptEnvironment):
"""To clean up after a map."""
if env == None:
raise SequenceError("Cannot delete a value from an empty environment.")
for i in args:
if i in env.globalVars:
del(env.globalVars[i])
_requireEnv = {'get' : getCustomValue, 'set' : setCustomValue, 'del' : delCustomValue}
_requireScripts = {'if' : ifScript}
def runScript(script: list, env: ScriptEnvironment, externalScripts: dict):
"""run a script"""
ret = False
for line in script:
if len(line) == 0:
# empty line
continue
elif line[0].casefold() == 'playercmd':
# run a player command
self.getIO('playercmd')(line[1:])
elif line[0].casefold() in _requireScripts:
ret = _requireScripts[line[0]](line[1:], env, externalScripts)
elif line[0].casefold() in _requireEnv:
ret = _requireEnv[line[0]](line[1:], env)
elif line[0].casefold() == 'break':
# exit early
return ScriptBreak(ret)
elif line[0] in externalScripts:
# run a script
ret = externalScripts[line[0]](line[1:])
else:
# conditional evaluation
return compareValues(line, env)
if isinstance(ret, ScriptBreak):
return ret
# We specifically want the return value of the last line executed.
return ret
def parseScript(instr: str, envGlobal: dict, externalScripts: dict):
"""parses then runs a script."""
#print('parseScript called with {}.'.format(instr))
if instr == '':
return # nothing to be done.
literalStr = list(instr)
inQuotes = False
script = []
stack = []
ret = []
argStart = 0
c = 0
l = 0
while c < len(instr):
if inQuotes:
if instr[c] == '"':
if instr[c-1] == '\\':
del literalStr[l-1]
l -= 1
else:
inQuotes = False
del literalStr[l]
l -= 1
else:
if instr[c] == '"': # quoted string
if l > 0 and instr[c-1] == '\\':
del literalStr[l-1]
l -= 1
else:
inQuotes = True
del literalStr[l]
l -= 1
elif instr[c] in ' \t\n;}{':
if l > 0 and instr[c-1] == '\\':
del literalStr[l-1]
l -= 1
else:
if argStart != l:
ret.append(''.join(literalStr[argStart:l]))
if instr[c] == ';':
if len(ret) > 0:
script.append(ret)
ret = []
elif instr[c] == '{': # block
stack.append(script)
script.append(ret)
script = []
ret.append(script)
ret = []
del literalStr[l]
l -= 1
elif instr[c] == '}': # close block
if len(ret) > 0:
script.append(ret)
ret = []
script = stack.pop()
del literalStr[l]
l -= 1
argStart = l + 1
c += 1
l += 1
if argStart != l:
ret.append(''.join(literalStr[argStart:l]))
if len(ret) > 0:
script.append(ret)
env = ScriptEnvironment(envGlobal, {})
ret = runScript(script, env, externalScripts)
if isinstance(ret, ScriptBreak):
ret = ret.value
return ret