diff --git a/bink/__init__.py b/bink/__init__.py index f2eef1e..578ce92 100644 --- a/bink/__init__.py +++ b/bink/__init__.py @@ -1,60 +1,11 @@ -"""Load the Bink C library.""" -import os -import platform -import ctypes.util +"""Python bindings for the Blade Ink C API.""" -def _load_library(): - # First, look for a bundled bink shared object on the lib folder. - system = platform.system() - library_name = None - if system == 'Windows': - library_name = 'bink.dll' - elif system == 'Darwin': - library_name = 'libbink.dylib' - else: - library_name = 'libbink.so' +from ._ffi import (BINK_ERROR_ERROR, BINK_ERROR_WARNING, BINK_FAIL, + BINK_FAIL_INVALID_ARGUMENT, BINK_FAIL_INVALID_UTF8, + BINK_FAIL_NUL_BYTE, BINK_FAIL_NULL_POINTER, + BINK_FAIL_PANIC, BINK_OK, BINK_VALUE_BOOL, + BINK_VALUE_DIVERT_TARGET, BINK_VALUE_FLOAT, BINK_VALUE_INT, + BINK_VALUE_LIST, BINK_VALUE_STRING, + BINK_VALUE_VARIABLE_POINTER, LIB) - arch = "x86_64/" - - if platform.machine() == "arm64": - arch = "arm64/" - - _filename = os.path.join(os.path.dirname(__file__), 'native/' + arch + library_name) - - # If no bundled shared object is found, look for a system-wide installed one. - if not os.path.exists(_filename): - # on windows all ctypes does when checking for the library - # is to append .dll to the end and look for an exact match - # within any entry in PATH. - _filename = ctypes.util.find_library('bink') - - if _filename is None: - if platform.system() == 'Windows': - # Check current working directory for dll as ctypes fails to do so - _filename = os.path.join(os.path.realpath('.'), "bink.dll") - else: - _filename = library_name - - try: - #print("lib filename: ", _filename) - lib = ctypes.CDLL(_filename) - except (OSError, TypeError) as exc: - lib = None - raise RuntimeError('bink library not found') from exc - return lib - -LIB = _load_library() - -LIB.bink_story_new.argtypes = [ - ctypes.POINTER( - ctypes.c_void_p), ctypes.c_char_p, ctypes.POINTER( - ctypes.c_char_p)] -LIB.bink_story_new.restype = ctypes.c_int - -LIB.bink_story_can_continue.argtypes = [ - ctypes.c_void_p, ctypes.POINTER(ctypes.c_bool)] -LIB.bink_story_can_continue.restype = ctypes.c_int - -BINK_OK = 0 -BINK_FAIL = 1 -BINK_FAIL_NULL_POINTER = 2 +__all__ = [name for name in globals() if name.startswith("BINK_")] + ["LIB"] diff --git a/bink/_ffi.py b/bink/_ffi.py new file mode 100644 index 0000000..aecaac6 --- /dev/null +++ b/bink/_ffi.py @@ -0,0 +1,98 @@ +"""Low-level ctypes declarations shared by the public wrappers.""" +import ctypes +import ctypes.util +import os +import platform + +BINK_OK, BINK_FAIL, BINK_FAIL_NULL_POINTER, BINK_FAIL_INVALID_UTF8, BINK_FAIL_NUL_BYTE, BINK_FAIL_PANIC, BINK_FAIL_INVALID_ARGUMENT = range(7) +BINK_ERROR_WARNING, BINK_ERROR_ERROR = range(2) +BINK_VALUE_BOOL, BINK_VALUE_INT, BINK_VALUE_FLOAT, BINK_VALUE_STRING, BINK_VALUE_LIST, BINK_VALUE_DIVERT_TARGET, BINK_VALUE_VARIABLE_POINTER = range(7) + + +def _load_library(): + names = {"Windows": "bink.dll", "Darwin": "libbink.dylib"} + library_name = names.get(platform.system(), "libbink.so") + arch = "arm64" if platform.machine() in ("arm64", "aarch64") else "x86_64" + filename = os.path.join(os.path.dirname(__file__), "native", arch, library_name) + if not os.path.exists(filename): + filename = ctypes.util.find_library("bink") or library_name + try: + return ctypes.CDLL(filename) + except (OSError, TypeError) as exc: + raise RuntimeError("bink library not found") from exc + + +LIB = _load_library() +P = ctypes.c_void_p +CP = ctypes.POINTER(ctypes.c_char_p) +SZ = ctypes.c_size_t + + +def _declare(name, args, restype=ctypes.c_int): + fn = getattr(LIB, name) + fn.argtypes, fn.restype = args, restype + + +for _name, _args in { + "bink_story_new": [ctypes.POINTER(P), ctypes.c_char_p, CP], + "bink_story_can_continue": [P, ctypes.POINTER(ctypes.c_bool), CP], + "bink_story_cont": [P, CP, CP], "bink_story_continue_maximally": [P, CP, CP], + "bink_story_continue_async": [P, ctypes.c_float, ctypes.POINTER(ctypes.c_bool), CP], + "bink_story_get_current_text": [P, CP, CP], + "bink_story_get_current_choices": [P, ctypes.POINTER(P), ctypes.POINTER(SZ), CP], + "bink_story_choose_choice_index": [P, SZ, CP], + "bink_story_get_current_tags": [P, ctypes.POINTER(P), ctypes.POINTER(SZ), CP], + "bink_story_get_global_tags": [P, ctypes.POINTER(P), ctypes.POINTER(SZ), CP], + "bink_story_get_tags_for_content_at_path": [P, ctypes.c_char_p, ctypes.POINTER(P), ctypes.POINTER(SZ), CP], + "bink_story_choose_path_string": [P, ctypes.c_char_p, CP], + "bink_story_choose_path_string_with_args": [P, ctypes.c_char_p, ctypes.c_bool, P, CP], + "bink_story_evaluate_function": [P, ctypes.c_char_p, P, ctypes.POINTER(P), CP, CP], + "bink_story_load_state": [P, ctypes.c_char_p, CP], "bink_story_save_state": [P, CP, CP], + "bink_story_reset_state": [P, CP], + "bink_story_get_visit_count_at_path_string": [P, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int32), CP], + "bink_story_get_current_path": [P, CP, CP], "bink_story_build_string_of_hierarchy": [P, CP, CP], + "bink_choices_get_text": [P, SZ, CP, CP], "bink_choices_get_tags": [P, SZ, ctypes.POINTER(P), ctypes.POINTER(SZ), CP], + "bink_tags_get": [P, SZ, CP, CP], + "bink_value_new_bool": [ctypes.c_bool, ctypes.POINTER(P), CP], "bink_value_new_int": [ctypes.c_int32, ctypes.POINTER(P), CP], + "bink_value_new_float": [ctypes.c_float, ctypes.POINTER(P), CP], "bink_value_new_string": [ctypes.c_char_p, ctypes.POINTER(P), CP], + "bink_value_get_bool": [P, ctypes.POINTER(ctypes.c_bool), CP], "bink_value_get_int": [P, ctypes.POINTER(ctypes.c_int32), CP], + "bink_value_get_float": [P, ctypes.POINTER(ctypes.c_float), CP], "bink_value_get_string": [P, CP, CP], + "bink_value_get_kind": [P, ctypes.POINTER(ctypes.c_int), CP], + "bink_value_array_new": [ctypes.POINTER(P), CP], "bink_value_array_push": [P, P, CP], + "bink_list_new": [ctypes.POINTER(P), CP], "bink_story_list_new_from_origin": [P, ctypes.c_char_p, ctypes.POINTER(P), CP], + "bink_story_list_new_from_item": [P, ctypes.c_char_p, ctypes.POINTER(P), CP], "bink_list_add_item": [P, ctypes.c_char_p, ctypes.c_int32, CP], + "bink_list_get_count": [P, ctypes.POINTER(SZ), CP], "bink_list_get_item": [P, SZ, CP, ctypes.POINTER(ctypes.c_int32), CP], + "bink_list_get_origin_count": [P, ctypes.POINTER(SZ), CP], "bink_list_get_origin": [P, SZ, CP, CP], + "bink_value_new_list": [P, ctypes.POINTER(P), CP], "bink_value_get_list": [P, ctypes.POINTER(P), CP], + "bink_var_get": [P, ctypes.c_char_p, ctypes.POINTER(P), CP], "bink_var_set": [P, ctypes.c_char_p, P, CP], + "bink_story_switch_flow": [P, ctypes.c_char_p, CP], "bink_story_remove_flow": [P, ctypes.c_char_p, CP], + "bink_story_switch_to_default_flow": [P, CP], "bink_story_set_allow_external_function_fallbacks": [P, ctypes.c_bool, CP], + "bink_unbind_external_function": [P, ctypes.c_char_p, CP], + "bink_fun_args_count": [P, ctypes.POINTER(SZ), CP], "bink_fun_args_get": [P, SZ, ctypes.POINTER(P), CP], +}.items(): + _declare(_name, _args) + +for _name, _args in {"bink_story_free": [P], "bink_choices_free": [P], "bink_tags_free": [P], "bink_value_free": [P], "bink_value_array_free": [P], "bink_list_free": [P], "bink_cstring_free": [ctypes.c_char_p]}.items(): + _declare(_name, _args, None) + + +def check(result, error): + if result == BINK_OK: + return + message = error.value.decode("utf-8") if error.value else "Blade Ink FFI error" + if error.value: + LIB.bink_cstring_free(error) + raise RuntimeError(message) + + +def call(name, *args): + error = ctypes.c_char_p() + check(getattr(LIB, name)(*args, ctypes.byref(error)), error) + + +def take_string(value): + try: + return value.value.decode("utf-8") if value.value else "" + finally: + if value.value: + LIB.bink_cstring_free(value) diff --git a/bink/choices.py b/bink/choices.py index c2a0fe7..8a95f89 100644 --- a/bink/choices.py +++ b/bink/choices.py @@ -1,63 +1,36 @@ -# pylint: disable=E1101, R0903 -"""Handle Ink Choices.""" +"""Choice collections returned by a story.""" import ctypes -from bink import LIB, BINK_OK - -class ChoicesIterator: - """Iterator for choices.""" - def __init__(self, choices): - self._choices = choices - self._index = 0 - - def __next__(self): - if self._index >= len(self._choices): - raise StopIteration - - self._index += 1 - return self._choices[self._index - 1] +from ._ffi import LIB, call, take_string +from .tags import Tags class Choices: - """List of story choices.""" - def __init__(self, choices, c_len: int): - self._choices = choices - self._len = c_len - - def __len__(self) -> int: - """Returns the number of choices.""" - return self._len - - def __bool__(self) -> bool: - return self._len != 0 - - def __iter__(self): - return ChoicesIterator(self) - - def __getitem__(self, idx: int) -> str: - """Returns the choice text""" - - if not isinstance(idx, int): - raise TypeError - - if idx < 0 or idx >= self._len: - raise IndexError - - return self.get_text(idx) - - def get_text(self, idx) -> str: - """Returns the choice text.""" - text = ctypes.c_char_p() - ret = LIB.bink_choices_get_text(self._choices, idx, ctypes.byref(text)) - - if ret != BINK_OK: - raise RuntimeError( - "Error getting choice text, index out of bounds?") - - result = text.value.decode('utf-8') - LIB.bink_cstring_free(text) - - return result + """An owned sequence of choice text, with access to each choice's tags.""" + def __init__(self, pointer, length): + self._choices, self._len = pointer, length + + def __len__(self): return self._len + def __bool__(self): return bool(self._len) + def __iter__(self): return (self[index] for index in range(self._len)) + + def _index(self, index): + if not isinstance(index, int): raise TypeError("choice index must be an integer") + if index < 0: index += self._len + if not 0 <= index < self._len: raise IndexError("choice index out of range") + return index + + def __getitem__(self, index): + value = ctypes.c_char_p() + call("bink_choices_get_text", self._choices, self._index(index), ctypes.byref(value)) + return take_string(value) + + def get_tags(self, index): + pointer, length = ctypes.c_void_p(), ctypes.c_size_t() + call("bink_choices_get_tags", self._choices, self._index(index), ctypes.byref(pointer), ctypes.byref(length)) + return Tags(pointer, length.value) def __del__(self): - LIB.bink_choices_free(self._choices) + if getattr(self, "_choices", None): + LIB.bink_choices_free(self._choices) + self._choices = None diff --git a/bink/native/arm64/libbink.dylib b/bink/native/arm64/libbink.dylib index 5a46ec4..2feb06b 100755 Binary files a/bink/native/arm64/libbink.dylib and b/bink/native/arm64/libbink.dylib differ diff --git a/bink/native/arm64/libbink.so b/bink/native/arm64/libbink.so index 35218a4..030a907 100755 Binary files a/bink/native/arm64/libbink.so and b/bink/native/arm64/libbink.so differ diff --git a/bink/native/x86_64/bink.dll b/bink/native/x86_64/bink.dll index 4255b09..4014991 100644 Binary files a/bink/native/x86_64/bink.dll and b/bink/native/x86_64/bink.dll differ diff --git a/bink/native/x86_64/libbink.dylib b/bink/native/x86_64/libbink.dylib index e67afc3..5e3f633 100755 Binary files a/bink/native/x86_64/libbink.dylib and b/bink/native/x86_64/libbink.dylib differ diff --git a/bink/native/x86_64/libbink.so b/bink/native/x86_64/libbink.so index bad4b7c..e333480 100755 Binary files a/bink/native/x86_64/libbink.so and b/bink/native/x86_64/libbink.so differ diff --git a/bink/story.py b/bink/story.py index d0803d4..d4b93f7 100644 --- a/bink/story.py +++ b/bink/story.py @@ -1,180 +1,133 @@ -# pylint: disable=E1101, C0116 - -"""Handle Ink Story.""" +"""High-level Story API over blade-ink-ffi.""" import ctypes -from bink.choices import Choices -from bink.tags import Tags -from bink import LIB, BINK_OK +from ._ffi import (BINK_ERROR_ERROR, LIB, P, call, take_string) +from .choices import Choices +from .tags import Tags +from .value import InkList, Value, ValueArray -class Story: - """Story is the entry point of the Blade Ink lib.""" - def __init__(self, story_string: str): - err_msg = ctypes.c_char_p() - story = ctypes.c_void_p() - ret = LIB.bink_story_new( - ctypes.byref(story), - story_string.encode('utf-8'), - ctypes.byref(err_msg)) - if ret != BINK_OK: - err = err_msg.value.decode('utf-8') - LIB.bink_cstring_free(err_msg) - raise RuntimeError(err) +_ERROR_HANDLER = ctypes.CFUNCTYPE(None, ctypes.c_char_p, ctypes.c_int, P) +_EXTERNAL_FUNCTION = ctypes.CFUNCTYPE(P, ctypes.c_char_p, P, P) +_VARIABLE_OBSERVER = ctypes.CFUNCTYPE(None, ctypes.c_char_p, P, P) +LIB.bink_story_set_error_handler.argtypes = [P, _ERROR_HANDLER, P, ctypes.POINTER(ctypes.c_char_p)] +LIB.bink_bind_external_function.argtypes = [P, ctypes.c_char_p, _EXTERNAL_FUNCTION, P, ctypes.POINTER(ctypes.c_char_p)] +LIB.bink_bind_external_function_with_options.argtypes = [P, ctypes.c_char_p, _EXTERNAL_FUNCTION, P, ctypes.c_bool, ctypes.POINTER(ctypes.c_char_p)] +LIB.bink_observe_variable.argtypes = [P, ctypes.c_char_p, _VARIABLE_OBSERVER, P, ctypes.POINTER(ctypes.c_char_p)] +LIB.bink_observe_variable_with_handle.argtypes = [P, ctypes.c_char_p, _VARIABLE_OBSERVER, P, ctypes.POINTER(P), ctypes.POINTER(ctypes.c_char_p)] +LIB.bink_variable_observer_remove.argtypes = [P, P, ctypes.POINTER(ctypes.c_char_p)] - self._story = story - def __next__(self): - if not self.can_continue(): - raise StopIteration +class Story: + """An Ink story. Every feature in the blade-ink-ffi C API is exposed here.""" + def __init__(self, story_string): + self._story = ctypes.c_void_p() + self._callbacks, self._observer_handles = [], [] + call("bink_story_new", ctypes.byref(self._story), story_string.encode()) + def __iter__(self): return self + def __next__(self): + if not self.can_continue(): raise StopIteration return self.cont() - - def __iter__(self): - return self - @property - def choices(self): - return self.get_current_choices() - + def choices(self): return self.get_current_choices() + @property + def tags(self): return self.get_current_tags() + @property + def current_text(self): return self.get_current_text() @property - def tags(self): - return self.get_current_tags() + def current_path(self): return self.get_current_path() def can_continue(self): - can_continue = ctypes.c_bool() - ret = LIB.bink_story_can_continue( - self._story, ctypes.byref(can_continue)) - - if ret != BINK_OK: - raise RuntimeError("Error in can_continue") - - return can_continue.value - - def cont(self) -> str: - err_msg = ctypes.c_char_p() - line = ctypes.c_char_p() - ret = LIB.bink_story_cont( - self._story, - ctypes.byref(line), - ctypes.byref(err_msg)) - - if ret != BINK_OK: - err = err_msg.value.decode('utf-8') - LIB.bink_cstring_free(err_msg) - raise RuntimeError(err) - - result = line.value.decode('utf-8') - LIB.bink_cstring_free(line) - - return result - - def continue_maximally(self) -> str: - err_msg = ctypes.c_char_p() - line = ctypes.c_char_p() - ret = LIB.bink_story_continue_maximally( - self._story, ctypes.byref(line), ctypes.byref(err_msg)) - - if ret != BINK_OK: - err = err_msg.value.decode('utf-8') - LIB.bink_cstring_free(err_msg) - raise RuntimeError(err) - - result = line.value.decode('utf-8') - LIB.bink_cstring_free(line) - - return result - - def get_current_choices(self) -> Choices: - choices = ctypes.c_void_p() - choice_count = ctypes.c_int() - ret = LIB.bink_story_get_current_choices( - self._story, ctypes.byref(choices), ctypes.byref(choice_count)) - - if ret != BINK_OK: - raise RuntimeError("Error getting current choices") - - choices = Choices(choices, choice_count.value) - - return choices - - def choose_choice_index(self, choice_index: int): - """Chooses the `Choice` from the - `currentChoices` list with the given index. Internally, this - sets the current content path to what the - `Choice` points to, ready to continue story evaluation.""" - err_msg = ctypes.c_char_p() - cidx = ctypes.c_int(choice_index) - ret = LIB.bink_story_choose_choice_index( - self._story, cidx, ctypes.byref(err_msg)) - - if ret != BINK_OK: - err = err_msg.value.decode('utf-8') - LIB.bink_cstring_free(err_msg) - raise RuntimeError(err) - - def get_current_tags(self) -> Tags: - tags = ctypes.c_void_p() - tag_count = ctypes.c_int() - ret = LIB.bink_story_get_current_tags( - self._story, ctypes.byref(tags), ctypes.byref(tag_count)) - - if ret != BINK_OK: - raise RuntimeError("Error getting current tags") - - tags = Tags(tags, tag_count.value) - - return tags - - def choose_path_string(self, path: str): - err_msg = ctypes.c_char_p() - ret = LIB.bink_story_choose_path_string( - self._story, - path.encode('utf-8'), - ctypes.byref(err_msg)) - if ret != BINK_OK: - err = err_msg.value.decode('utf-8') - LIB.bink_cstring_free(err_msg) - raise RuntimeError(err) - - def save_state(self) -> str: - """Saves the current state of the story and returns it as a string. - The returned state can be loaded later using load_state().""" - err_msg = ctypes.c_char_p() - save_string = ctypes.c_char_p() - ret = LIB.bink_story_save_state( - self._story, - ctypes.byref(save_string), - ctypes.byref(err_msg)) - - if ret != BINK_OK: - err = err_msg.value.decode('utf-8') - LIB.bink_cstring_free(err_msg) - raise RuntimeError(err) - - result = save_string.value.decode('utf-8') - LIB.bink_cstring_free(save_string) - - return result - - def load_state(self, save_state: str): - """Loads a previously saved state into the story. - This allows resuming the story from a saved point.""" - err_msg = ctypes.c_char_p() - ret = LIB.bink_story_load_state( - self._story, - save_state.encode('utf-8'), - ctypes.byref(err_msg)) - - if ret != BINK_OK: - err = err_msg.value.decode('utf-8') - LIB.bink_cstring_free(err_msg) - raise RuntimeError(err) + result = ctypes.c_bool(); call("bink_story_can_continue", self._story, ctypes.byref(result)); return result.value + def cont(self): return self._string("bink_story_cont") + def continue_maximally(self): return self._string("bink_story_continue_maximally") + def get_current_text(self): return self._string("bink_story_get_current_text") + def get_current_path(self): return self._string("bink_story_get_current_path") + def build_string_of_hierarchy(self): return self._string("bink_story_build_string_of_hierarchy") + def save_state(self): return self._string("bink_story_save_state") + def _string(self, function): + result = ctypes.c_char_p(); call(function, self._story, ctypes.byref(result)); return take_string(result) + + def continue_async(self, millisecs_limit_async): + complete = ctypes.c_bool(); call("bink_story_continue_async", self._story, millisecs_limit_async, ctypes.byref(complete)); return complete.value + def get_current_choices(self): return self._collection("bink_story_get_current_choices", Choices) + def get_current_tags(self): return self._collection("bink_story_get_current_tags", Tags) + def get_global_tags(self): return self._collection("bink_story_get_global_tags", Tags) + def _collection(self, function, cls, *args): + pointer, length = ctypes.c_void_p(), ctypes.c_size_t() + call(function, self._story, *args, ctypes.byref(pointer), ctypes.byref(length)); return cls(pointer, length.value) + def get_tags_for_content_at_path(self, path): return self._collection("bink_story_get_tags_for_content_at_path", Tags, path.encode()) + + def choose_choice_index(self, index): call("bink_story_choose_choice_index", self._story, index) + def choose_path_string(self, path): call("bink_story_choose_path_string", self._story, path.encode()) + def choose_path_string_with_args(self, path, args=(), reset_call_stack=True): + values = args if isinstance(args, ValueArray) else ValueArray(args) + call("bink_story_choose_path_string_with_args", self._story, path.encode(), reset_call_stack, values._values) + def evaluate_function(self, name, args=()): + values = args if isinstance(args, ValueArray) else ValueArray(args) + result, text = ctypes.c_void_p(), ctypes.c_char_p() + call("bink_story_evaluate_function", self._story, name.encode(), values._values, ctypes.byref(result), ctypes.byref(text)) + return Value(_pointer=result).to_python(), take_string(text) + def load_state(self, state): call("bink_story_load_state", self._story, state.encode()) + def reset_state(self): call("bink_story_reset_state", self._story) + def get_visit_count_at_path_string(self, path): + count = ctypes.c_int32(); call("bink_story_get_visit_count_at_path_string", self._story, path.encode(), ctypes.byref(count)); return count.value + + def get_variable(self, name): + value = ctypes.c_void_p(); call("bink_var_get", self._story, name.encode(), ctypes.byref(value)); return Value(_pointer=value).to_python() + def set_variable(self, name, value): + value = value if isinstance(value, Value) else Value(value); call("bink_var_set", self._story, name.encode(), value._value) + def list_from_origin(self, origin): return self._story_list("bink_story_list_new_from_origin", origin) + def list_from_item(self, item): return self._story_list("bink_story_list_new_from_item", item) + def _story_list(self, function, name): + result = ctypes.c_void_p(); call(function, self._story, name.encode(), ctypes.byref(result)); return InkList(_pointer=result) + + def switch_flow(self, name): call("bink_story_switch_flow", self._story, name.encode()) + def remove_flow(self, name): call("bink_story_remove_flow", self._story, name.encode()) + def switch_to_default_flow(self): call("bink_story_switch_to_default_flow", self._story) + def set_allow_external_function_fallbacks(self, allow): call("bink_story_set_allow_external_function_fallbacks", self._story, allow) + + def bind_external_function(self, name, function, lookahead_safe=False): + def callback(c_name, c_args, _): + try: + count = ctypes.c_size_t(); call("bink_fun_args_count", c_args, ctypes.byref(count)) + args = [] + for index in range(count.value): + value = ctypes.c_void_p(); call("bink_fun_args_get", c_args, index, ctypes.byref(value)); args.append(Value(_pointer=value).to_python()) + result = function(c_name.decode(), *args) + if result is None: + return None + value = Value(result) + value._owned = False # Ownership is transferred to blade-ink-ffi. + return value._value.value + except Exception: + return None + callback = _EXTERNAL_FUNCTION(callback); self._callbacks.append(callback) + function_name = "bink_bind_external_function_with_options" if lookahead_safe else "bink_bind_external_function" + if lookahead_safe: call(function_name, self._story, name.encode(), callback, None, True) + else: call(function_name, self._story, name.encode(), callback, None) + def unbind_external_function(self, name): call("bink_unbind_external_function", self._story, name.encode()) + + def set_error_handler(self, handler): + callback = _ERROR_HANDLER(lambda message, kind, _: handler(message.decode(), kind == BINK_ERROR_ERROR)) + self._callbacks.append(callback); call("bink_story_set_error_handler", self._story, callback, None) + def observe_variable(self, name, observer, removable=False): + def callback(c_name, c_value, _): + try: + observer(c_name.decode(), Value(_pointer=c_value, _owned=False).to_python()) + except Exception: + pass + callback = _VARIABLE_OBSERVER(callback); self._callbacks.append(callback) + if not removable: + call("bink_observe_variable", self._story, name.encode(), callback, None); return None + handle = ctypes.c_void_p(); call("bink_observe_variable_with_handle", self._story, name.encode(), callback, None, ctypes.byref(handle)); self._observer_handles.append(handle); return handle + def remove_variable_observer(self, handle): + call("bink_variable_observer_remove", self._story, handle) + if handle in self._observer_handles: self._observer_handles.remove(handle) def __del__(self): - LIB.bink_story_free(self._story) + if getattr(self, "_story", None): LIB.bink_story_free(self._story); self._story = None -def story_from_file(story_file: str): - with open(story_file, 'r', encoding='utf-8') as file: - content = file.read() - return Story(content) +def story_from_file(story_file): + with open(story_file, encoding="utf-8") as file: return Story(file.read()) diff --git a/bink/tags.py b/bink/tags.py index 5712979..b5bcba6 100644 --- a/bink/tags.py +++ b/bink/tags.py @@ -1,63 +1,27 @@ -# pylint: disable=E1101, R0903 - -"""Handle Ink tags.""" +"""Tag collections returned by a story or choice.""" import ctypes -from bink import LIB, BINK_OK - - -class TagsIterator: - """Iterator for tags.""" - def __init__(self, tags): - self._tags = tags - self._index = 0 - - def __next__(self): - if self._index >= len(self._tags): - raise StopIteration - self._index += 1 - return self._tags[self._index - 1] +from ._ffi import LIB, call, take_string class Tags: - """Contains a list of tags.""" - def __init__(self, tags, c_len: int): - self._tags = tags - self._len = c_len - - def __len__(self) -> int: - """Returns the number of tags.""" - return self._len - - def __bool__(self) -> bool: - return self._len != 0 - - def __iter__(self): - return TagsIterator(self) - - def __getitem__(self, idx: int) -> str: - """Returns the tag text""" - - if not isinstance(idx, int): - raise TypeError - - if idx < 0 or idx >= self._len: - raise IndexError - - return self.get(idx) - - def get(self, idx) -> str: - """Returns the tag text.""" - tag = ctypes.c_char_p() - ret = LIB.bink_tags_get(self._tags, idx, ctypes.byref(tag)) - - if ret != BINK_OK: - raise RuntimeError("Error getting tag, index out of bounds?") - - result = tag.value.decode('utf-8') - LIB.bink_cstring_free(tag) - - return result + """An owned, immutable sequence of Ink tags.""" + def __init__(self, pointer, length): + self._tags, self._len = pointer, length + + def __len__(self): return self._len + def __bool__(self): return bool(self._len) + def __iter__(self): return (self[index] for index in range(self._len)) + + def __getitem__(self, index): + if not isinstance(index, int): raise TypeError("tag index must be an integer") + if index < 0: index += self._len + if not 0 <= index < self._len: raise IndexError("tag index out of range") + value = ctypes.c_char_p() + call("bink_tags_get", self._tags, index, ctypes.byref(value)) + return take_string(value) def __del__(self): - LIB.bink_tags_free(self._tags) + if getattr(self, "_tags", None): + LIB.bink_tags_free(self._tags) + self._tags = None diff --git a/bink/value.py b/bink/value.py new file mode 100644 index 0000000..b09c90b --- /dev/null +++ b/bink/value.py @@ -0,0 +1,83 @@ +"""Typed Ink values, value arrays, and Ink lists.""" +import ctypes + +from ._ffi import (BINK_VALUE_BOOL, BINK_VALUE_FLOAT, BINK_VALUE_INT, + BINK_VALUE_LIST, BINK_VALUE_STRING, LIB, call, take_string) + + +class Value: + """An owned FFI value. Construct it from bool, int, float, str, or InkList.""" + def __init__(self, value=None, _pointer=None, _owned=True): + self._owned = _owned + if _pointer is not None: + self._value = _pointer + return + self._value = ctypes.c_void_p() + if isinstance(value, InkList): call("bink_value_new_list", value._list, ctypes.byref(self._value)) + elif isinstance(value, bool): call("bink_value_new_bool", value, ctypes.byref(self._value)) + elif isinstance(value, int): call("bink_value_new_int", value, ctypes.byref(self._value)) + elif isinstance(value, float): call("bink_value_new_float", value, ctypes.byref(self._value)) + elif isinstance(value, str): call("bink_value_new_string", value.encode(), ctypes.byref(self._value)) + else: raise TypeError("Value must be bool, int, float, str, or InkList") + + @property + def kind(self): + kind = ctypes.c_int(); call("bink_value_get_kind", self._value, ctypes.byref(kind)); return kind.value + + def to_python(self): + if self.kind == BINK_VALUE_BOOL: + out = ctypes.c_bool(); call("bink_value_get_bool", self._value, ctypes.byref(out)); return out.value + if self.kind == BINK_VALUE_INT: + out = ctypes.c_int32(); call("bink_value_get_int", self._value, ctypes.byref(out)); return out.value + if self.kind == BINK_VALUE_FLOAT: + out = ctypes.c_float(); call("bink_value_get_float", self._value, ctypes.byref(out)); return out.value + if self.kind == BINK_VALUE_STRING: + out = ctypes.c_char_p(); call("bink_value_get_string", self._value, ctypes.byref(out)); return take_string(out) + if self.kind == BINK_VALUE_LIST: + out = ctypes.c_void_p(); call("bink_value_get_list", self._value, ctypes.byref(out)); return InkList(_pointer=out) + raise TypeError("this Ink value has no Python representation") + + def __del__(self): + if getattr(self, "_value", None) and self._owned: LIB.bink_value_free(self._value) + self._value = None + + +class ValueArray: + """Owned argument array for function evaluation and path selection.""" + def __init__(self, values=()): + self._values = ctypes.c_void_p(); call("bink_value_array_new", ctypes.byref(self._values)) + for value in values: self.append(value) + def append(self, value): + owned = value if isinstance(value, Value) else Value(value) + call("bink_value_array_push", self._values, owned._value) + def __del__(self): + if getattr(self, "_values", None): LIB.bink_value_array_free(self._values); self._values = None + + +class InkList: + """An owned Ink list of ``(origin.item, value)`` entries.""" + def __init__(self, items=(), _pointer=None): + self._list = _pointer or ctypes.c_void_p() + if _pointer is None: + call("bink_list_new", ctypes.byref(self._list)) + for name, value in items: self.add(name, value) + def add(self, full_name, value): call("bink_list_add_item", self._list, full_name.encode(), value) + def __len__(self): + count = ctypes.c_size_t(); call("bink_list_get_count", self._list, ctypes.byref(count)); return count.value + @property + def items(self): + result = [] + for index in range(len(self)): + name, value = ctypes.c_char_p(), ctypes.c_int32() + call("bink_list_get_item", self._list, index, ctypes.byref(name), ctypes.byref(value)) + result.append((take_string(name), value.value)) + return result + @property + def origins(self): + count = ctypes.c_size_t(); call("bink_list_get_origin_count", self._list, ctypes.byref(count)) + result = [] + for index in range(count.value): + origin = ctypes.c_char_p(); call("bink_list_get_origin", self._list, index, ctypes.byref(origin)); result.append(take_string(origin)) + return result + def __del__(self): + if getattr(self, "_list", None): LIB.bink_list_free(self._list); self._list = None diff --git a/inkfiles/function/func-basic.ink.json b/inkfiles/function/func-basic.ink.json new file mode 100644 index 0000000..e0f6ed4 --- /dev/null +++ b/inkfiles/function/func-basic.ink.json @@ -0,0 +1 @@ +{"inkVersion":21,"root":[["ev",2,8,0.4,{"f()":"lerp"},"/ev",{"VAR=":"x","re":true},"\n","^The value of x is ","ev",{"VAR?":"x"},"out","/ev","^.","\n","end",["done",{"#n":"g-0"}],null],"done",{"lerp":[{"temp=":"k"},{"temp=":"b"},{"temp=":"a"},"ev",{"VAR?":"b"},{"VAR?":"a"},"-",{"VAR?":"k"},"*",{"VAR?":"a"},"+","/ev","~ret",null],"global decl":["ev",0.0,{"VAR=":"x"},"/ev","end",null]}],"listDefs":{}} \ No newline at end of file diff --git a/inkfiles/lists/basic-operations.ink.json b/inkfiles/lists/basic-operations.ink.json new file mode 100644 index 0000000..6977758 --- /dev/null +++ b/inkfiles/lists/basic-operations.ink.json @@ -0,0 +1 @@ +{"inkVersion":21,"root":[["ev",{"VAR?":"list"},"out","/ev","\n","ev",{"list":{"list.a":1,"list.c":3}},{"list":{"list.b":2,"list.e":5}},"+","out","/ev","\n","ev",{"list":{"list.a":1,"list.b":2,"list.c":3}},{"list":{"list.c":3,"list.b":2,"list.e":5}},"L^","out","/ev","\n","ev",{"VAR?":"list"},{"list":{"list.b":2,"list.d":4,"list.e":5}},"?","out","/ev","\n","ev",{"VAR?":"list"},{"list":{"list.d":4,"list.b":2}},"?","out","/ev","\n","ev",{"VAR?":"list"},{"list":{"list.c":3}},"!?","out","/ev","\n",["done",{"#n":"g-0"}],null],"done",{"global decl":["ev",{"list":{"list.b":2,"list.d":4}},{"VAR=":"list"},"/ev","end",null]}],"listDefs":{"list":{"a":1,"b":2,"c":3,"d":4,"e":5}}} \ No newline at end of file diff --git a/inkfiles/runtime/external-function-2-arg.ink.json b/inkfiles/runtime/external-function-2-arg.ink.json new file mode 100644 index 0000000..a402fe5 --- /dev/null +++ b/inkfiles/runtime/external-function-2-arg.ink.json @@ -0,0 +1 @@ +{"inkVersion":21,"root":[["^The value is ","ev",3,4.0,{"x()":"externalFunction","exArgs":2},"out","/ev","^.","\n","end",["done",{"#n":"g-0"}],null],"done",{"externalFunction":[{"temp=":"y"},{"temp=":"x"},"ev",{"VAR?":"x"},{"VAR?":"y"},"+","/ev","~ret",null]}],"listDefs":{}} \ No newline at end of file diff --git a/inkfiles/runtime/multiflow-basics.ink.json b/inkfiles/runtime/multiflow-basics.ink.json new file mode 100644 index 0000000..09d69db --- /dev/null +++ b/inkfiles/runtime/multiflow-basics.ink.json @@ -0,0 +1 @@ +{"inkVersion":21,"root":[[["done",{"#n":"g-0"}],null],"done",{"knot1":["^knot 1 line 1","\n","^knot 1 line 2","\n","end",null],"knot2":["^knot 2 line 1","\n","^knot 2 line 2","\n","end",null]}],"listDefs":{}} \ No newline at end of file diff --git a/inkfiles/runtime/variable-observers.ink.json b/inkfiles/runtime/variable-observers.ink.json new file mode 100644 index 0000000..5316003 --- /dev/null +++ b/inkfiles/runtime/variable-observers.ink.json @@ -0,0 +1 @@ +{"inkVersion":21,"root":[["ev",5,"/ev",{"VAR=":"x","re":true},["ev",{"^->":"0.4.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":"0.c-0","flg":18},{"s":["^Sets x = 10",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":"0.4.s"},[{"#n":"$r2"}],"\n","ev",10,"/ev",{"VAR=":"x","re":true},"end",{"->":"0.g-0"},{"#f":5}],"g-0":["done",null]}],"done",{"global decl":["ev",0,{"VAR=":"x"},"/ev","end",null]}],"listDefs":{}} \ No newline at end of file diff --git a/inkfiles/tagsInChoice.ink.json b/inkfiles/tagsInChoice.ink.json new file mode 100644 index 0000000..5184748 --- /dev/null +++ b/inkfiles/tagsInChoice.ink.json @@ -0,0 +1 @@ +{"inkVersion":21,"root":[[["ev",{"^->":"0.0.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","str","^two ","#","^two","/#","/str","/ev",{"*":"0.c-0","flg":6},{"s":["^one ","#","^one ","/#",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":"0.0.s"},[{"#n":"$r2"}],"^ three ","#","^three ","/#","end","\n",{"->":"0.g-0"},null],"g-0":["done",null]}],"done",null],"listDefs":{}} \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 3a6bd66..d0e2dbf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = bink -version = 0.5.0 +version = 0.7.0 author = Rafael Garcia description = Runtime for Ink, a scripting language for writing interactive narrative long_description = file: README.rst diff --git a/tests/test_ffi_api.py b/tests/test_ffi_api.py new file mode 100644 index 0000000..378a0af --- /dev/null +++ b/tests/test_ffi_api.py @@ -0,0 +1,70 @@ +"""Integration coverage for the complete public blade-ink-ffi binding surface.""" +import unittest + +from bink.story import story_from_file +from bink.value import InkList, Value, ValueArray + + +class FfiApiTestCase(unittest.TestCase): + def test_values_lists_and_function_evaluation(self): + self.assertEqual(Value(True).to_python(), True) + self.assertEqual(Value(7).to_python(), 7) + self.assertAlmostEqual(Value(1.25).to_python(), 1.25) + self.assertEqual(Value("hello").to_python(), "hello") + values = ValueArray([2, 8, 0.4]) + story = story_from_file("inkfiles/function/func-basic.ink.json") + result, output = story.evaluate_function("lerp", values) + self.assertAlmostEqual(result, 4.4, places=6) + self.assertEqual(output, "") + ink_list = InkList([("letters.a", 1), ("letters.b", 2)]) + self.assertEqual(ink_list.items, [("letters.a", 1), ("letters.b", 2)]) + self.assertEqual(ink_list.origins, ["letters", "letters"]) + self.assertEqual(Value(ink_list).to_python().items, ink_list.items) + list_story = story_from_file("inkfiles/lists/basic-operations.ink.json") + self.assertEqual(list_story.list_from_origin("list").origins, ["list"]) + self.assertEqual(list_story.list_from_item("list.a").items, [("list.a", 1)]) + + def test_runtime_state_variables_observers_and_external_functions(self): + story = story_from_file("inkfiles/runtime/variable-observers.ink.json") + observed = [] + handle = story.observe_variable("x", lambda name, value: observed.append((name, value)), removable=True) + story.continue_maximally() + self.assertEqual(observed, [("x", 5)]) + self.assertEqual(story.get_variable("x"), 5) + story.set_variable("x", 9) + self.assertEqual(story.get_variable("x"), 9) + story.remove_variable_observer(handle) + + story = story_from_file("inkfiles/runtime/external-function-2-arg.ink.json") + story.set_allow_external_function_fallbacks(True) + story.bind_external_function("externalFunction", lambda name, x, y: x - y) + self.assertEqual(story.continue_maximally(), "The value is -1.\n") + story.unbind_external_function("externalFunction") + + def test_story_navigation_async_flows_and_introspection(self): + story = story_from_file("inkfiles/runtime/multiflow-basics.ink.json") + story.switch_flow("secondary") + self.assertEqual(story.continue_maximally(), "") + story.choose_path_string("knot1") + self.assertTrue(story.continue_async(1000)) + self.assertEqual(story.current_text, "knot 1 line 1\n") + self.assertIn("knot1", story.current_path) + self.assertIn("knot1", story.build_string_of_hierarchy()) + self.assertEqual(story.get_visit_count_at_path_string("knot1"), 0) + story.switch_to_default_flow() + story.remove_flow("secondary") + story.reset_state() + self.assertTrue(story.can_continue()) + + def test_tags_choices_paths_and_error_handler(self): + story = story_from_file("inkfiles/tags.ink.json") + story.set_error_handler(lambda message, is_error: None) + self.assertEqual(list(story.get_global_tags()), ["author: Joe", "title: My Great Story"]) + story.cont() + self.assertEqual(list(story.tags), ["author: Joe", "title: My Great Story"]) + story.choose_path_string_with_args("knot", (), reset_call_stack=True) + self.assertEqual(story.cont(), "Knot content\n") + self.assertEqual(list(story.get_tags_for_content_at_path("knot")), ["knot tag"]) + choice_story = story_from_file("inkfiles/tagsInChoice.ink.json") + choice_story.continue_maximally() + self.assertEqual(list(choice_story.choices.get_tags(0)), ["one", "two"])