Skip to content

Commit

Permalink
Merge pull request relaypro-open#6 from josefalanga/register-custom-f…
Browse files Browse the repository at this point in the history
…unctions

Register custom functions
  • Loading branch information
weaversam8 authored Jun 26, 2023
2 parents 7750de2 + c9f698b commit 8458a75
Show file tree
Hide file tree
Showing 12 changed files with 164 additions and 28 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,11 @@ A few gotchas to look out for:
As of version v0.0.2, all Yarn Spinner opcodes are currently implemented, as well as Yarn Spinner 1's internal standard library of functions and operators. As of version v0.2.1, typed versions of these functions (introduced in Yarn Spinner 2) are present, but full YS2 parity has not been verified at this time. The known features currently missing are:

- Localisation and Line IDs [(see Yarn's Localization docs)](https://docs.yarnspinner.dev/using-yarnspinner-with-unity/assets-and-localization)
- An appropriate replacement for the distinction Yarn makes between Functions and Coroutines in Unity (to allow users to register blocking command handlers via this Python runner independent of Unity)
- An appropriate replacement for the distinction Yarn makes between Functions and Coroutines in Unity (to allow users to register asynchronous command handlers via this Python runner independent of Unity)
- Complete implementation of YS2's type system, specifically when performing operations on mismatching types
- This may be challenging, due to Python being a dynamically typed language
- The [`<<wait>>` built-in command](https://docs.yarnspinner.dev/getting-started/writing-in-yarn/commands#wait)
- The [`<<wait>>` built-in command](https://docs.yarnspinner.dev/getting-started/writing-in-yarn/commands#wait), but can be added as a custom command using `runner.add_command_handler()`.
- Most YS2 [built-in functions](https://docs.yarnspinner.dev/getting-started/writing-in-yarn/functions#built-in-functions) are not defined in this runtime, but can be added as custom functions using `runner.add_function_handler()`.

## Development

Expand Down
1 change: 1 addition & 0 deletions examples/yarn1/functions-metadata.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
id,node,lineNumber,tags
4 changes: 4 additions & 0 deletions examples/yarn1/functions.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
id,text,file,node,lineNumber
line:C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn-Start-0,One plus one is {0},C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn,Start,3
line:C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn-Start-1,You rolled a six!,C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn,Start,7
line:C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn-Start-2,Gambler: My lucky number is {0}!,C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn,Start,11
13 changes: 13 additions & 0 deletions examples/yarn1/functions.yarn
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
title: Start
---
One plus one is {add_numbers(1, 1)}

// Inside an if statement:
<<if dice(6) == 6>>
You rolled a six!
<<endif>>

// Inside a line:
Gambler: My lucky number is {random_range(1,10)}!

===
Binary file added examples/yarn1/functions.yarnc
Binary file not shown.
1 change: 1 addition & 0 deletions examples/yarn2/functions-metadata.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
id,node,lineNumber,tags
4 changes: 4 additions & 0 deletions examples/yarn2/functions.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
id,text,file,node,lineNumber
line:C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn-Start-0,One plus one is {0},C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn,Start,3
line:C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn-Start-1,You rolled a six!,C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn,Start,7
line:C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn-Start-2,Gambler: My lucky number is {0}!,C:\Documents\development\YarnRunner-Python\examples\yarn1\functions.yarn,Start,11
13 changes: 13 additions & 0 deletions examples/yarn2/functions.yarn
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
title: Start
---
One plus one is {add_numbers(1, 1)}

// Inside an if statement:
<<if dice(6) == 6>>
You rolled a six!
<<endif>>

// Inside a line:
Gambler: My lucky number is {random_range(1,10)}!

===
Binary file added examples/yarn2/functions.yarnc
Binary file not shown.
8 changes: 1 addition & 7 deletions tests/test_experimental_newlines.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,7 @@
__file__), '../examples/yarn2/experimental-newlines.csv'), 'r')

runner_normal = YarnRunner(compiled_yarn_f, names_csv_f)

# reset file position
compiled_yarn_f.seek(0, 0)
names_csv_f.seek(0, 0)

runner_experimental = YarnRunner(
compiled_yarn_f, names_csv_f, experimental_newlines=True)
runner_experimental = YarnRunner(compiled_yarn_f, names_csv_f, experimental_newlines=True)


def test_normal_newlines():
Expand Down
69 changes: 69 additions & 0 deletions tests/test_functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import os
import random
from .context import YarnRunner

compiled_yarn_f1 = open(os.path.join(os.path.dirname(
__file__), '../examples/yarn1/functions.yarnc'), 'rb')
names_csv_f1 = open(os.path.join(os.path.dirname(
__file__), '../examples/yarn1/functions.csv'), 'r')
compiled_yarn_f2 = open(os.path.join(os.path.dirname(
__file__), '../examples/yarn2/functions.yarnc'), 'rb')
names_csv_f2 = open(os.path.join(os.path.dirname(
__file__), '../examples/yarn2/functions.csv'), 'r')

# autostart=False so the runner doesn't start before the functions are registered
runner1 = YarnRunner(compiled_yarn_f1, names_csv_f1, autostart=False)
runner2 = YarnRunner(compiled_yarn_f2, names_csv_f2, autostart=False)


def add_numbers(first: int, second: int) -> int:
return int(first + second)


def dice(faces: int) -> int:
return int(6)


def random_range(start: int, stop: int) -> int:
return int(6)


def test_run_functions_1():
runner1.add_function_handler("add_numbers", add_numbers)
runner1.add_function_handler("dice", dice)
runner1.add_function_handler("random_range", random_range)

runner1.resume()

lines = runner1.get_lines()
assert lines[0] == "One plus one is 2"
assert lines[1] == "You rolled a six!"
assert lines[2] == "Gambler: My lucky number is 6!"


def test_run_functions_2():
runner2.add_function_handler("add_numbers", add_numbers)
runner2.add_function_handler("dice", dice)
runner2.add_function_handler("random_range", random_range)

runner2.resume()

lines = runner2.get_lines()
assert lines[0] == "One plus one is 2"
assert lines[1] == "You rolled a six!"
assert lines[2] == "Gambler: My lucky number is 6!"


def test_function_invocation_without_handler():
function_name = "add_numbers"

runner3 = YarnRunner(compiled_yarn_f2, names_csv_f2, autostart=False)
try:
runner3.resume()

# the runner should throw an error
raise Exception(
"The runner ran without any issues. This test should fail. An Exception was expected.")
except Exception as e:
assert str(
e) == f"The function `{function_name}` is not implemented, and is not registered as a custom function."
74 changes: 55 additions & 19 deletions yarnrunner_python/runner.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import csv
import re
from inspect import signature
from typing import Callable
from warnings import warn
from google.protobuf import json_format
from .yarn_spinner_pb2 import Program as YarnProgram, Instruction
Expand All @@ -8,6 +10,10 @@

class YarnRunner(object):
def __init__(self, compiled_yarn_f, names_csv_f, autostart=True, enable_tracing=False, experimental_newlines=False) -> None:
# reset files' position before reading
compiled_yarn_f.seek(0, 0)
names_csv_f.seek(0, 0)

self._compiled_yarn = YarnProgram()
self._compiled_yarn.ParseFromString(compiled_yarn_f.read())
self._names_csv = csv.DictReader(names_csv_f)
Expand All @@ -20,6 +26,7 @@ def __init__(self, compiled_yarn_f, names_csv_f, autostart=True, enable_tracing=
self.current_node = None
self._node_stack = []
self._command_handlers = {}
self._function_handlers = {}
self._line_buffer = []
self._option_buffer = []
self._vm_data_stack = ["Start"]
Expand Down Expand Up @@ -147,8 +154,27 @@ def choose(self, index):
self._vm_data_stack.insert(0, choice["choice"])
self.resume()

def add_command_handler(self, command, fn):
self._command_handlers[command] = fn
def add_command_handler(self, command: str, cmd: Callable):
"""
Registers a custom command that can be invoked from Yarn scripts.
Like `<<get_player_name>>` or `<<walk MyCharacter StageLeft>>`.
Usually these commands don't return anything. Returning a string, will put it on the line where is used.
More info on https://docs.yarnspinner.dev/using-yarnspinner-with-unity/creating-commands-functions#defining-commands
:param command: A string representing how the command in invoked from the Yarn script
:param cmd: A function for handling the invocation.
"""
self._command_handlers[command] = cmd

def add_function_handler(self, function, fn):
"""
Registers a custom function that can be invoked from Yarn scripts.
Like `dice(6)` or `random_range(1,10)`.
These functions return something and can be used in conditionals and lines as well.
More info on https://docs.yarnspinner.dev/using-yarnspinner-with-unity/creating-commands-functions#defining-functions
:param function: A string representing how the function is invoked from the Yarn script
:param fn: A function for handling the invocation.
"""
self._function_handlers[function] = fn

def climb_node_stack(self):
if len(self._node_stack) < 1:
Expand Down Expand Up @@ -307,27 +333,37 @@ def __pop(self, _instruction):

def __call_func(self, instruction):
function_name = instruction.operands[0].string_value
if function_name not in std_lib_functions:
raise Exception(
f"The internal function `{function_name}` is not implemented in this Yarn runtime.")

expected_params, fn = std_lib_functions[function_name]
actual_params = int(self._vm_data_stack.pop(0))

if expected_params != actual_params:
raise Exception(
f"The internal function `{function_name} expects {expected_params} parameters but received {actual_params} parameters")

params = []
while expected_params > 0:
params.insert(0, self._vm_data_stack.pop(0))
expected_params -= 1
def execute_std():
expected_params, fn = std_lib_functions[function_name]
params = extract_params(expected_params)
return fn(params)

# invoke the function
ret = fn(params)
def execute_custom():
fn = self._function_handlers[function_name]
params = extract_params(len(signature(fn).parameters))
return fn(*params)

def extract_params(expected_params):
actual_params = int(self._vm_data_stack.pop(0))
if expected_params != actual_params:
raise Exception(
f"The function `{function_name} expects {expected_params} parameters but received {actual_params} parameters")
params = []
while expected_params > 0:
params.insert(0, self._vm_data_stack.pop(0))
expected_params -= 1
return params

if function_name in std_lib_functions:
result = execute_std()
elif function_name in self._function_handlers:
result = execute_custom()
else:
raise Exception(
f"The function `{function_name}` is not implemented, and is not registered as a custom function.")
# Store the return value on the stack
self._vm_data_stack.insert(0, ret)
self._vm_data_stack.insert(0, result)

def __push_variable(self, instruction):
variable_name = instruction.operands[0].string_value
Expand Down

0 comments on commit 8458a75

Please sign in to comment.