diff --git a/.github/.cspell/gamedev_dictionary.txt b/.github/.cspell/gamedev_dictionary.txt index 1c8bbb3e149..43e6cdac452 100644 --- a/.github/.cspell/gamedev_dictionary.txt +++ b/.github/.cspell/gamedev_dictionary.txt @@ -18,6 +18,7 @@ abelian ambiguate antialiasing arial +arity autofocus backpressure backtick diff --git a/packages/flame_jenny/jenny/lib/src/command_storage.dart b/packages/flame_jenny/jenny/lib/src/command_storage.dart new file mode 100644 index 00000000000..e37cd9b6220 --- /dev/null +++ b/packages/flame_jenny/jenny/lib/src/command_storage.dart @@ -0,0 +1,345 @@ +import 'dart:async'; + +import 'package:jenny/src/errors.dart'; +import 'package:jenny/src/parse/ascii.dart'; +import 'package:meta/meta.dart'; + +/// [CommandStorage] is the repository of user-defined commands known to the +/// YarnProject. +/// +/// This repository is populated by the user, using commands [addCommand0], +/// [addCommand1], [addCommand2], [addCommand3], and [addDialogueCommand], +/// depending on the arity of the function that needs to be invoked. All user- +/// defined commands need to be declared before parsing any Yarn scripts. +class CommandStorage { + CommandStorage() : _commands = {}; + + final Map _commands; + + /// Tokens that represent valid true/false values when converting an argument + /// into a boolean. These sets can be modified by the user. + static Set trueValues = {'true', 'yes', 'on', '+', 'T', '1'}; + static Set falseValues = {'false', 'no', 'off', '-', 'F', '0'}; + + /// Returns `true` if command with the given [name] has been registered. + bool hasCommand(String name) => _commands.containsKey(name); + + /// Registers a no-arguments function [fn] as a custom yarn command [name]. + void addCommand0(String name, FutureOr Function() fn) { + _checkName(name); + _commands[name] = _Cmd(name, const [], (List args) => fn()); + } + + /// Registers a single-argument function [fn] as a custom yarn command [name]. + void addCommand1(String name, FutureOr Function(T1) fn) { + _checkName(name); + _commands[name] = _Cmd(name, [T1], (List args) => fn(args[0] as T1)); + } + + /// Registers a 2-arguments function [fn] as a custom yarn command [name]. + void addCommand2(String name, FutureOr Function(T1, T2) fn) { + _checkName(name); + _commands[name] = + _Cmd(name, [T1, T2], (List args) => fn(args[0] as T1, args[1] as T2)); + } + + /// Registers a 3-arguments function [fn] as a custom yarn command [name]. + void addCommand3( + String name, + FutureOr Function(T1, T2, T3) fn, + ) { + _checkName(name); + _commands[name] = _Cmd( + name, + [T1, T2, T3], + (List args) => fn(args[0] as T1, args[1] as T2, args[2] as T3), + ); + } + + /// Registers a command [name] which is not backed by any Dart function. + /// Instead, this command will be delivered directly to the dialogue views. + void addDialogueCommand(String name) { + _commands[name] = null; + } + + /// Executes the command [name], passing it the arguments as a single string + /// [argString]. The caller should check beforehand that the command with + /// such a name exists. + @internal + FutureOr runCommand(String name, String argString) { + final cmd = _commands[name]; + if (cmd != null) { + final stringArgs = ArgumentsLexer(argString).tokenize(); + final typedArgs = cmd.unpackArguments(stringArgs); + return cmd.run(typedArgs); + } + } + + /// Sanity checks for whether it is valid to add a command [name]. + void _checkName(String name) { + assert(!hasCommand(name), 'Command <<$name>> has already been defined'); + assert(!_builtinCommands.contains(name), 'Command <<$name>> is built-in'); + assert( + _rxId.firstMatch(name) != null, + 'Command name "$name" is not an identifier', + ); + } + + static final _rxId = RegExp(r'^[a-zA-Z_]\w*$'); + + /// The list of built-in commands in Jenny; the user is not allowed to + /// register a command with the same name. Some of the commands in the list + /// are reserved for future use. + static const List _builtinCommands = [ + 'declare', + 'else', + 'elseif', + 'endif', + 'for', + 'if', + 'jump', + 'local', + 'set', + 'stop', + 'stop', + 'wait', + 'while', + ]; +} + +/// A wrapper around Dart function, which allows that function to be invoked +/// dynamically from the Yarn runtime. +class _Cmd { + _Cmd(this.name, List types, this._wrappedFn) + : _signature = _unpackTypes(types), + _arguments = List.filled(types.length, null) { + numTrailingBooleans = + _signature.reversed.takeWhile((type) => type == _Type.boolean).length; + } + + final String name; + final List<_Type> _signature; + final FutureOr Function(List) _wrappedFn; + final List _arguments; + late final int numTrailingBooleans; + + FutureOr run(List arguments) { + return _wrappedFn(arguments); + } + + List unpackArguments(List stringArguments) { + if (stringArguments.length > _arguments.length || + stringArguments.length + numTrailingBooleans < _arguments.length) { + String plural(int num, String word) => '$num $word${num == 1 ? '' : 's'}'; + throw TypeError( + 'Command <<$name>> expects ${plural(_arguments.length, 'argument')} ' + 'but received ${plural(stringArguments.length, 'argument')}', + ); + } + for (var i = 0; i < numTrailingBooleans; i++) { + _arguments[_arguments.length - i - 1] = false; + } + for (var i = 0; i < stringArguments.length; i++) { + final strValue = stringArguments[i]; + switch (_signature[i]) { + case _Type.boolean: + if (CommandStorage.falseValues.contains(strValue)) { + _arguments[i] = false; + } else if (CommandStorage.trueValues.contains(strValue)) { + _arguments[i] = true; + } else { + throw TypeError( + 'Argument ${i + 1} for command <<$name>> has value "$strValue", ' + 'which is not a boolean', + ); + } + break; + case _Type.integer: + final value = int.tryParse(strValue); + if (value == null) { + throw TypeError( + 'Argument ${i + 1} for command <<$name>> has value "$strValue", ' + 'which is not integer', + ); + } + _arguments[i] = value; + break; + case _Type.double: + final value = double.tryParse(strValue); + if (value == null) { + throw TypeError( + 'Argument ${i + 1} for command <<$name>> has value "$strValue", ' + 'which is not a floating-point value', + ); + } + _arguments[i] = value; + break; + case _Type.numeric: + final value = num.tryParse(strValue); + if (value == null) { + throw TypeError( + 'Argument ${i + 1} for command <<$name>> has value "$strValue", ' + 'which is not numeric', + ); + } + _arguments[i] = value; + break; + case _Type.string: + _arguments[i] = strValue; + break; + } + } + return _arguments; + } + + static List<_Type> _unpackTypes(List types) { + final result = List.filled(types.length, _Type.string); + for (var i = 0; i < types.length; i++) { + final expressionType = _typeMap[types[i]]; + assert( + expressionType != null, + 'Unsupported type ${types[i]} of argument ${i + 1}', + ); + result[i] = expressionType!; + } + return result; + } + + static const Map _typeMap = { + bool: _Type.boolean, + int: _Type.integer, + double: _Type.double, + num: _Type.numeric, + String: _Type.string, + }; +} + +@visibleForTesting +class ArgumentsLexer { + ArgumentsLexer(this.text); + + final String text; + int position = 0; + List<_ModeFn> modeStack = []; + List tokens = []; + StringBuffer buffer = StringBuffer(); + + List tokenize() { + pushMode(modeStartOfArgument); + while (!eof) { + final ok = (modeStack.last)(); + assert(ok); + } + if (modeStack.last == modeTextArgument) { + if (buffer.isNotEmpty) { + finalizeArgument(); + } + } else if (modeStack.last == modeQuotedArgument) { + throw DialogueError('Unterminated quoted string'); + } + assert(modeStack.last == modeStartOfArgument); + return tokens; + } + + bool get eof => position >= text.length; + + int get currentCodeUnit => + position < text.length ? text.codeUnitAt(position) : -1; + + bool pushMode(_ModeFn mode) { + modeStack.add(mode); + return true; + } + + //---------------------------------------------------------------------------- + + bool modeStartOfArgument() { + return eatWhitespace() || + (eatQuote() && pushMode(modeQuotedArgument)) || + pushMode(modeTextArgument); + } + + bool modeTextArgument() { + return (eatWhitespace() && finalizeArgument()) || eatCharacter(); + } + + bool modeQuotedArgument() { + return (eatQuote() && finalizeArgument() && checkWhitespaceAfterQuote()) || + eatEscapedCharacter() || + eatCharacter(); + } + + /// Returns true if current character is a whitespace, and skips over it. + bool eatWhitespace() { + final ch = currentCodeUnit; + if (ch == $space || ch == $tab) { + position += 1; + return true; + } + return false; + } + + /// Returns true if current character is `"`, and skips over it. + bool eatQuote() { + if (currentCodeUnit == $doubleQuote) { + position += 1; + return true; + } + return false; + } + + /// Consumes any character and writes it into the buffer. + bool eatCharacter() { + buffer.writeCharCode(currentCodeUnit); + position += 1; + return true; + } + + /// Consumes an escape sequence `\\`, `\"`, or `\n` and writes the + /// corresponding unescaped character into the buffer. + bool eatEscapedCharacter() { + if (currentCodeUnit == $backslash) { + position += 1; + final ch = currentCodeUnit; + if (ch == $backslash || ch == $doubleQuote) { + buffer.writeCharCode(ch); + } else if (ch == $lowercaseN) { + buffer.writeCharCode($lineFeed); + } else { + throw DialogueError( + 'Unrecognized escape sequence \\${String.fromCharCode(ch)}', + ); + } + position += 1; + return true; + } + return false; + } + + bool finalizeArgument() { + tokens.add(buffer.toString()); + buffer.clear(); + modeStack.removeLast(); + assert(modeStack.last == modeStartOfArgument); + return true; + } + + /// Throws an error if there is no whitespace after a quoted argument. + bool checkWhitespaceAfterQuote() { + if (eof || eatWhitespace()) { + return true; + } + throw DialogueError('Whitespace is required after a quoted argument'); + } +} + +typedef _ModeFn = bool Function(); + +/// Similar to `ExpressionType`, but also allows `integer`. +enum _Type { + boolean, + integer, + double, + numeric, + string, +} diff --git a/packages/flame_jenny/jenny/lib/src/dialogue_runner.dart b/packages/flame_jenny/jenny/lib/src/dialogue_runner.dart index 24811fb3b62..ff5027d0742 100644 --- a/packages/flame_jenny/jenny/lib/src/dialogue_runner.dart +++ b/packages/flame_jenny/jenny/lib/src/dialogue_runner.dart @@ -4,6 +4,7 @@ import 'package:jenny/src/dialogue_view.dart'; import 'package:jenny/src/errors.dart'; import 'package:jenny/src/structure/block.dart'; import 'package:jenny/src/structure/commands/command.dart'; +import 'package:jenny/src/structure/commands/user_defined_command.dart'; import 'package:jenny/src/structure/dialogue_choice.dart'; import 'package:jenny/src/structure/dialogue_line.dart'; import 'package:jenny/src/structure/node.dart'; @@ -140,7 +141,11 @@ class DialogueRunner { } FutureOr _deliverCommand(Command command) { - return command.execute(this); + return _combineFutures([ + command.execute(this), + if (command is UserDefinedCommand) + for (final view in _dialogueViews) view.onCommand(command) + ]); } @internal @@ -161,11 +166,17 @@ class DialogueRunner { _iterators.clear(); } - Future _combineFutures(List> maybeFutures) { - return Future.wait(>[ + FutureOr _combineFutures(List> maybeFutures) { + final futures = >[ for (final maybeFuture in maybeFutures) if (maybeFuture is Future) maybeFuture - ]); + ]; + if (futures.length == 1) { + return futures[0]; + } else if (futures.isNotEmpty) { + final Future result = Future.wait(futures); + return result; + } } Never _error(String message) { diff --git a/packages/flame_jenny/jenny/lib/src/dialogue_view.dart b/packages/flame_jenny/jenny/lib/src/dialogue_view.dart index 23c25800a30..c2189047579 100644 --- a/packages/flame_jenny/jenny/lib/src/dialogue_view.dart +++ b/packages/flame_jenny/jenny/lib/src/dialogue_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:jenny/src/errors.dart'; +import 'package:jenny/src/structure/commands/user_defined_command.dart'; import 'package:jenny/src/structure/dialogue_choice.dart'; import 'package:jenny/src/structure/dialogue_line.dart'; import 'package:jenny/src/structure/node.dart'; @@ -130,6 +131,12 @@ abstract class DialogueView { /// future to complete before proceeding with the dialogue. FutureOr onChoiceFinish(Option option) {} + /// Called when the dialogue encounters a user-defined command. + /// + /// If this method returns a future, the dialogue runner will wait for that + /// future to complete before proceeding with the dialogue. + FutureOr onCommand(UserDefinedCommand command) {} + /// Called when the dialogue has ended. /// /// This method can be used to clean up any of the dialogue UI. The returned diff --git a/packages/flame_jenny/jenny/lib/src/parse/parse.dart b/packages/flame_jenny/jenny/lib/src/parse/parse.dart index 5bfffb88f28..48064d5aa2f 100644 --- a/packages/flame_jenny/jenny/lib/src/parse/parse.dart +++ b/packages/flame_jenny/jenny/lib/src/parse/parse.dart @@ -8,6 +8,7 @@ import 'package:jenny/src/structure/commands/if_command.dart'; import 'package:jenny/src/structure/commands/jump_command.dart'; import 'package:jenny/src/structure/commands/set_command.dart'; import 'package:jenny/src/structure/commands/stop_command.dart'; +import 'package:jenny/src/structure/commands/user_defined_command.dart'; import 'package:jenny/src/structure/commands/wait_command.dart'; import 'package:jenny/src/structure/dialogue_choice.dart'; import 'package:jenny/src/structure/dialogue_line.dart'; @@ -104,6 +105,7 @@ class _Parser { } take(Token.endHeader); if (title == null) { + position -= 1; syntaxError('node does not have a title'); } return _NodeHeader(title, tags.isEmpty ? null : tags); @@ -520,7 +522,19 @@ class _Parser { } Command parseUserDefinedCommand() { - throw UnimplementedError('user-defined commands are not supported yet'); + take(Token.startCommand); + final commandToken = peekToken(); + position += 1; + assert(commandToken.isCommand); + final commandName = commandToken.content; + if (!project.commands.hasCommand(commandName)) { + position -= 1; + nameError('Unknown user-defined command <<$commandName>>'); + } + final arguments = parseLineContent(); + take(Token.endCommand); + takeNewline(); + return UserDefinedCommand(commandName, arguments); } late Map diff --git a/packages/flame_jenny/jenny/lib/src/parse/tokenize.dart b/packages/flame_jenny/jenny/lib/src/parse/tokenize.dart index 64cb8b041f2..a284919ca97 100644 --- a/packages/flame_jenny/jenny/lib/src/parse/tokenize.dart +++ b/packages/flame_jenny/jenny/lib/src/parse/tokenize.dart @@ -112,9 +112,6 @@ class _Lexer { return true; } - _ModeFn get currentMode => modeStack.last; - _ModeFn get parentMode => modeStack[modeStack.length - 2]; - /// Pops the last token from the output stack and checks that it is equal to /// [token]. Token popToken([Token? token]) { @@ -145,11 +142,11 @@ class _Lexer { /// Note that this mode is never popped off the mode stack. bool modeMain() { return eatEmptyLine() || - eatCommentLine() || + eatComment() || eatHashtagLine() || - eatCommandStart() || - eatHeaderStart() || - (pushMode(modeNodeHeader) && pushToken(Token.startHeader, position)); + (eatCommandStart() && pushMode(modeCommand)) || + (eatHeaderStart() && pushMode(modeNodeHeader)) || + (pushToken(Token.startHeader, position) && pushMode(modeNodeHeader)); } /// Parsing node header, this mode is only active at a start of a line, and @@ -159,10 +156,13 @@ class _Lexer { /// The mode switches to [modeNodeBody] upon encountering the '---' sequence. bool modeNodeHeader() { return eatEmptyLine() || - eatCommentLine() || + eatComment() || (eatId() && pushMode(modeNodeHeaderLine)) || (eatWhitespace() && error('unexpected indentation')) || - eatHeaderEnd() || + (eatHeaderEnd() && + popMode(modeNodeHeader) && + pushToken(Token.startBody, position) && + pushMode(modeNodeBody)) || error('expected end-of-header marker "---"'); } @@ -172,7 +172,7 @@ class _Lexer { bool modeNodeHeaderLine() { return eatWhitespace() || (eat($colon) && pushToken(Token.colon, position - 1)) || - eatHeaderRestOfLine(); + (eatHeaderRestOfLine() && popMode(modeNodeHeaderLine)); } /// The top-level mode for parsing the body of a Node. This mode is active at @@ -180,64 +180,98 @@ class _Lexer { /// taking care of whitespace. bool modeNodeBody() { return eatEmptyLine() || - eatCommentLine() || - eatBodyEnd() || + eatComment() || + (eatBodyEnd() && popMode(modeNodeBody)) || (eatIndent() && pushMode(modeNodeBodyLine)); } /// The mode for parsing regular lines of a node body. This mode is active at - /// the beginning of the line only, where it attempts to disambiguate between - /// what kind of line it is and then switches to either [modeCommand] or - /// [modeText]. + /// the beginning of the line only (after the indent), where it attempts to + /// disambiguate between what kind of line it is and then switches to either + /// [modeCommand] or [modeText]. bool modeNodeBodyLine() { - return eatArrow() || - eatCommandStart() || - eatCharacterName() || - eatWhitespace() || + return eatWhitespace() || + eatArrow() || + (eatCommandStart() && pushMode(modeCommand)) || + (eatCharacterName() && pushMode(modeText)) || (eatNewline() && popMode(modeNodeBodyLine)) || pushMode(modeText); } /// The mode of a regular text line within the node body. This mode will - /// consume the input until the end of the line, and then pop back to - /// [modeNodeBody]. + /// consume the input until the end of the line, and switch to [modeTextEnd]. bool modeText() { return eatTextEscapeSequence() || - eatExpressionStart() || eatPlainText() || - (popMode(modeText) && pushMode(modeLineEnd)); + eatCommandEndAsText() || + (eatExpressionStart() && pushMode(modeTextExpression)) || + (popMode(modeText) && pushMode(modeTextEnd)); + } + + /// Mode at the end of a line, allows hashtags and comments. + bool modeTextEnd() { + return eatWhitespace() || + eatComment() || + eatHashtag() || + (eatCommandStart() && pushMode(modeCommand)) || + (eatNewline() && popMode(modeTextEnd) && popMode(modeNodeBodyLine)); } /// The command mode starts with a '<<', then consumes the command name, and - /// after that either switches to the [modeExpression], or looks for a '>>' - /// token to exit the mode. + /// after that either switches to a different mode depending on which command + /// was encountered. bool modeCommand() { return eatWhitespace() || - eatCommandName() || - eatCommandEnd() || - eatCommandNewline(); + (eatCommandName() && + (false || // subsequent mode will depend on the command type + (simpleCommands.contains(tokens.last)) || + (bareExpressionCommands.contains(tokens.last) && + pushToken(Token.startExpression, position) && + pushMode(modeCommandExpression)) || + (tokens.last == Token.commandJump && + (eatId() || + (eatExpressionStart() && + pushMode(modeTextExpression)) || + error('an ID or an expression expected'))) || + (tokens.last.isCommand && // user-defined commands + pushMode(modeCommandText)))) || + (eatCommandEnd() && popMode(modeCommand)) || + checkNewlineInCommand(); + } + + /// Mode for the content of a user-defined command. It is almost the same as + /// [modeText], except that it ends at '>>'. + bool modeCommandText() { + return (eatExpressionStart() && pushMode(modeTextExpression)) || + (eatCommandEnd() && popMode(modeCommandText) && popMode(modeCommand)) || + eatTextEscapeSequence() || + eatPlainText(); } - /// An expression within a [modeText] or [modeCommand]. Within the text, the - /// expression is surrounded with curly braces `{ }`, inside a command the - /// expression starts immediately after the command name and ends at `>>`. - bool modeExpression() { + /// An expression within a [modeText] or [modeCommandText]. The expression + /// is surrounded with curly braces `{ }`. + bool modeTextExpression() { return eatWhitespace() || - eatExpressionCommandEnd() || eatExpressionId() || eatExpressionVariable() || eatNumber() || eatString() || eatOperator() || - eatExpressionEnd(); + (eatExpressionEnd() && popMode(modeTextExpression)); } - /// Mode at the end of a line, allows hashtags and comments. - bool modeLineEnd() { + /// An expression within a [modeCommand]. Such expression starts immediately + /// after the command name and ends at `>>`. + bool modeCommandExpression() { return eatWhitespace() || - eatCommentOrNewline() || - eatHashtag() || - eatCommandStart(); + (eatExpressionCommandEnd() && + popMode(modeCommandExpression) && + popMode(modeCommand)) || + eatExpressionId() || + eatExpressionVariable() || + eatNumber() || + eatString() || + eatOperator(); } //---------------------------------------------------------------------------- @@ -245,7 +279,6 @@ class _Lexer { // the current parsing location. If successful, the functions will: // - advance the parsing position [position]; // - emit 0 or more tokens into the [tokens] stream; - // - possibly pushes a new mode or pops the current mode; // - return `true`. // Otherwise, the function will: // - leave [position] unmodified; @@ -261,8 +294,8 @@ class _Lexer { return false; } - /// Consumes an empty line, i.e. a line consisting of whitespace only. Does - /// not emit any tokens. + /// Consumes an empty line, i.e. a line consisting of whitespace only. Emits + /// a newline token. bool eatEmptyLine() { final position0 = position; eatWhitespace(); @@ -277,17 +310,16 @@ class _Lexer { } } - /// Consumes a comment line: `\s*//.*` up to and including the newline, - /// without emitting any tokens. - bool eatCommentLine() { + /// Consumes a comment line: `\s*//.*` up to but not including the newline, + /// emitting no tokens. + bool eatComment() { final position0 = position; eatWhitespace(); if (eat($slash) && eat($slash)) { while (!eof) { final cu = currentCodeUnit; if (cu == $carriageReturn || cu == $lineFeed) { - eatNewline(); - break; + return true; } position += 1; } @@ -355,10 +387,9 @@ class _Lexer { return false; } - /// Consumes a start-of-header token (3 or more '-') followed by a newline, - /// then switches to the [modeNodeHeader]. Note that the same character - /// sequence encountered within the header body would mean the end of - /// header section. + /// Consumes a start-of-header token (3 or more '-') followed by a newline. + /// Note that the same character sequence encountered within the header body + /// would mean the end of header section. bool eatHeaderStart() { final position0 = position; var numMinuses = 0; @@ -368,15 +399,14 @@ class _Lexer { if (numMinuses >= 3 && eatNewline()) { popToken(Token.newline); pushToken(Token.startHeader, position0); - pushMode(modeNodeHeader); return true; } position = position0; return false; } - /// Consumes an end-of-header token '---' followed by a newline, emits a - /// token, and switches to the [modeNodeBody]. + /// Consumes an end-of-header token '---' followed by a newline, and emits a + /// corresponding token. bool eatHeaderEnd() { final position0 = position; var numMinuses = 0; @@ -386,17 +416,13 @@ class _Lexer { if (numMinuses >= 3 && eatNewline()) { popToken(Token.newline); pushToken(Token.endHeader, position0); - pushToken(Token.startBody, position0); - popMode(modeNodeHeader); - pushMode(modeNodeBody); return true; } position = position0; return false; } - /// Consumes an end-of-body token '===' followed by a newline, emits a token, - /// and pops the current mode. + /// Consumes+emits an end-of-body token '===' followed by a newline. bool eatBodyEnd() { final position0 = position; if (eat($equals) && eat($equals) && eat($equals)) { @@ -410,7 +436,6 @@ class _Lexer { return false; } pushToken(Token.endBody, position0); - popMode(modeNodeBody); return true; } position = position0; @@ -443,18 +468,15 @@ class _Lexer { final position0 = position; for (; !eof; position++) { final cu = currentCodeUnit; - if (cu == $carriageReturn || cu == $lineFeed) { - break; - } else if (cu == $slash && eat($slash) && eat($slash)) { - position -= 2; + if (cu == $carriageReturn || + cu == $lineFeed || + (cu == $slash && nextCodeUnit == $slash)) { break; } } pushToken(Token.text(text.substring(position0, position)), position0); - if (!eatNewline()) { - eatCommentLine(); - } - popMode(modeNodeHeaderLine); + eatComment(); + eatNewline(); return true; } @@ -494,21 +516,17 @@ class _Lexer { final position0 = position; if (eat($minus) && eat($greaterThan)) { pushToken(Token.arrow, position0); - eatWhitespace(); return true; } position = position0; return false; } - /// Consumes+emits a command-start token '<<', and switches to the - /// [modeCommand]. + /// Consumes+emits a command-start token '<<'. bool eatCommandStart() { final position0 = position; if (eat($lessThan) && eat($lessThan)) { - eatWhitespace(); pushToken(Token.startCommand, position0); - pushMode(modeCommand); return true; } position = position0; @@ -519,12 +537,7 @@ class _Lexer { bool eatCommandEnd() { final position0 = position; if (eat($greaterThan) && eat($greaterThan)) { - eatWhitespace(); pushToken(Token.endCommand, position0); - popMode(modeCommand); - if (currentMode != modeLineEnd) { - pushMode(modeLineEnd); - } return true; } position = position0; @@ -535,7 +548,7 @@ class _Lexer { /// emits a [Token.person] and a [Token.colon], and also switches into the /// [modeText]. /// - /// Note: we have to consume detect both the character name and the subsequent + /// Note: we have to detect both the character name and the subsequent /// ':' at the same time, because without the colon a simple word at a start /// of the line must be considered the plain text. bool eatCharacterName() { @@ -551,7 +564,6 @@ class _Lexer { final name = Token.person(text.substring(position0, position1)); pushToken(name, position0); pushToken(Token.colon, position1); - pushMode(modeText); return true; } } @@ -559,21 +571,6 @@ class _Lexer { return false; } - /// Consumes a comment at the end of line or a newline while in the - /// [modeLineEnd], and pops the current mode so that the next line will start - /// again at [modeNodeBody]. - bool eatCommentOrNewline() { - if (eatNewline() || eatCommentLine()) { - popMode(modeLineEnd); - if (currentMode == modeNodeBodyLine) { - popMode(modeNodeBodyLine); - assert(currentMode == modeNodeBody); - } - return true; - } - return false; - } - /// Consumes an escape syntax while in the [modeText]. An escape syntax /// consists of a backslash followed by the character being escaped. An error /// will be raised if the escaped character is invalid (e.g. '\1'). Emits a @@ -606,26 +603,14 @@ class _Lexer { return false; } - /// Consumes '{' and enters the [modeExpression]. + /// Consumes '{' token. bool eatExpressionStart() { - return eat($leftBrace) && - pushToken(Token.startExpression, position - 1) && - pushMode(modeExpression); + return eat($leftBrace) && pushToken(Token.startExpression, position - 1); } - /// Consumes '}' and pops the [modeExpression] (but only when the parent mode - /// is [modeText]). + /// Consumes '}' token. bool eatExpressionEnd() { - if (eat($rightBrace)) { - if (parentMode != modeText) { - position -= 1; - error('invalid token "}" within a command'); - } - pushToken(Token.endExpression, position - 1); - popMode(modeExpression); - return true; - } - return false; + return eat($rightBrace) && pushToken(Token.endExpression, position - 1); } /// Consumes '>>' while in the expression mode, and leaves both the expression @@ -634,15 +619,8 @@ class _Lexer { bool eatExpressionCommandEnd() { final position0 = position; if (eat($greaterThan) && eat($greaterThan)) { - if (parentMode != modeCommand) { - position -= 2; - error('invalid token ">>" within an expression'); - } pushToken(Token.endExpression, position0); pushToken(Token.endCommand, position0); - eatWhitespace(); - popMode(modeExpression); - popMode(modeCommand); return true; } position = position0; @@ -664,8 +642,10 @@ class _Lexer { position = positionBeforeWhitespace; break; } else if ((cu == $lessThan && nextCodeUnit == $lessThan) || + (cu == $greaterThan && nextCodeUnit == $greaterThan) || cu == $backslash || - cu == $leftBrace) { + cu == $leftBrace || + cu == $rightBrace) { break; } position += 1; @@ -680,6 +660,15 @@ class _Lexer { return false; } + bool eatCommandEndAsText() { + if (currentCodeUnit == $greaterThan && nextCodeUnit == $greaterThan) { + pushToken(const Token.text('>>'), position); + position += 2; + return true; + } + return false; + } + /// Consumes a plain id within an expression, which is then emitted as either /// one of the [keywords] tokens, or as plain [Token.id]. bool eatExpressionId() { @@ -803,35 +792,17 @@ class _Lexer { return false; } - /// Consumes a name of the command (ID) and emits it as a token. After that, - /// goes either into expression mode if the command expects arguments, or - /// remains in the command mode otherwise. User-defined commands are assumed - /// to always allow expressions. + /// Consumes a name of the command (ID) and emits it as a token. bool eatCommandName() { final position0 = position; if (eatId()) { + eatWhitespace(); final token = popToken(); - final name = token.content; - final commandToken = commandTokens[name]; + final commandToken = commandTokens[token.content]; if (commandToken != null) { pushToken(commandToken, position0); - if (commandToken == Token.commandIf || - commandToken == Token.commandElseif || - commandToken == Token.commandWait || - commandToken == Token.commandSet || - commandToken == Token.commandDeclare) { - pushToken(Token.startExpression, position0); - pushMode(modeExpression); - } else if (commandToken == Token.commandJump) { - eatWhitespace(); - eatId() || - eatExpressionStart() || - error('an ID or an expression expected'); - } } else { - pushToken(Token.command(name), position0); - pushToken(Token.startExpression, position0); - pushMode(modeExpression); + pushToken(Token.command(token.content), position0); } return true; } @@ -839,7 +810,7 @@ class _Lexer { } /// Check whether a command terminated prematurely. - bool eatCommandNewline() { + bool checkNewlineInCommand() { final cu = currentCodeUnit; if (cu == $carriageReturn || cu == $lineFeed) { error('missing command close token ">>"'); @@ -854,11 +825,8 @@ class _Lexer { if (eat($hash)) { while (!eof) { final cu = currentCodeUnit; - if (cu == $slash && eat($slash) && eat($slash)) { - position -= 2; - break; - } - if (cu == $lineFeed || + if ((cu == $slash && nextCodeUnit == $slash) || + cu == $lineFeed || cu == $carriageReturn || cu == $space || cu == $tab || @@ -938,6 +906,22 @@ class _Lexer { 'wait': Token.commandWait, }; + /// Built-in commands that have no arguments. + static final Set simpleCommands = { + Token.commandElse, + Token.commandEndif, + Token.commandStop, + }; + + /// Built-in commands that are followed by an expression (without `{}`). + static final Set bareExpressionCommands = { + Token.commandDeclare, + Token.commandElseif, + Token.commandIf, + Token.commandSet, + Token.commandWait, + }; + /// Throws a [SyntaxError] with the given [message], augmenting it with the /// information about the current parsing location. /// diff --git a/packages/flame_jenny/jenny/lib/src/structure/commands/user_defined_command.dart b/packages/flame_jenny/jenny/lib/src/structure/commands/user_defined_command.dart new file mode 100644 index 00000000000..b1f03ce0557 --- /dev/null +++ b/packages/flame_jenny/jenny/lib/src/structure/commands/user_defined_command.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:jenny/src/dialogue_runner.dart'; +import 'package:jenny/src/structure/commands/command.dart'; +import 'package:jenny/src/structure/expressions/expression.dart'; + +class UserDefinedCommand extends Command { + UserDefinedCommand(this.name, this.argumentString); + + @override + final String name; + final StringExpression argumentString; + + @override + FutureOr execute(DialogueRunner dialogue) { + return dialogue.project.commands.runCommand(name, argumentString.value); + } +} diff --git a/packages/flame_jenny/jenny/lib/src/yarn_project.dart b/packages/flame_jenny/jenny/lib/src/yarn_project.dart index 0400620e39d..7a5c36d34bf 100644 --- a/packages/flame_jenny/jenny/lib/src/yarn_project.dart +++ b/packages/flame_jenny/jenny/lib/src/yarn_project.dart @@ -1,3 +1,4 @@ +import 'package:jenny/src/command_storage.dart'; import 'package:jenny/src/parse/parse.dart' as impl; import 'package:jenny/src/structure/node.dart'; import 'package:jenny/src/variable_storage.dart'; @@ -5,20 +6,28 @@ import 'package:jenny/src/variable_storage.dart'; /// [YarnProject] is a central place where all dialogue-related information /// is held: /// - [nodes]: the map of nodes parsed from yarn files; -/// - [variables]: the repository of all variables accessible to yarn scripts; +/// - [variables]: the repository of all global variables accessible to yarn +/// scripts; /// - [functions]: user-defined functions; /// - [commands]: user-defined commands; /// class YarnProject { YarnProject() : nodes = {}, - variables = VariableStorage(); + variables = VariableStorage(), + commands = CommandStorage(); /// All parsed [Node]s, keyed by their titles. final Map nodes; final VariableStorage variables; + final CommandStorage commands; + + /// Parses a single yarn file, given as a [text] string. + /// + /// This method may be called multiple times, in order to load as many yarn + /// scripts as necessary. void parse(String text) { impl.parse(text, this); } diff --git a/packages/flame_jenny/jenny/test/command_storage_test.dart b/packages/flame_jenny/jenny/test/command_storage_test.dart new file mode 100644 index 00000000000..2f40b207424 --- /dev/null +++ b/packages/flame_jenny/jenny/test/command_storage_test.dart @@ -0,0 +1,279 @@ +import 'package:jenny/src/command_storage.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + group('CommandStorage', () { + test('A dialogue command', () { + final storage = CommandStorage(); + storage.addDialogueCommand('simple'); + expect(storage.hasCommand('simple'), true); + storage.runCommand('simple', ''); + storage.runCommand('simple', '1 2 3'); + expect( + () => storage.addCommand0('simple', () => null), + throwsAssertionError('Command <> has already been defined'), + ); + }); + + test('A no-argument command', () { + final storage = CommandStorage(); + storage.addCommand0('foo', () => null); + + expect(storage.hasCommand('foo'), true); + expect(storage.hasCommand('bar'), false); + }); + + test('An integer-argument command', () { + var value = -1; + final storage = CommandStorage(); + storage.addCommand1('one', (int x) => value = x); + expect(storage.hasCommand('one'), true); + + void check(String arg, int expectedValue) { + storage.runCommand('one', arg); + expect(value, expectedValue); + } + + check('23', 23); + check(' 117 ', 117); + check('-3', -3); + check('+666', 666); + check(' "42" ', 42); + expect( + () => storage.runCommand('one', '2.0'), + hasTypeError( + 'TypeError: Argument 1 for command <> has value "2.0", which ' + 'is not integer', + ), + ); + }); + + test('A boolean-argument command', () { + var value = false; + final storage = CommandStorage(); + storage.addCommand1('check', (bool x) => value = x); + expect(storage.hasCommand('check'), true); + + void check(String arg, bool expectedValue) { + storage.runCommand('check', arg); + expect(value, expectedValue); + } + + check('true', true); + check(' false ', false); + check('+', true); + check('-', false); + check(' "on"', true); + check('off ', false); + check('yes', true); + check('no', false); + check('T', true); + check('F', false); + check('1', true); + check('0', false); + expect( + () => storage.runCommand('check', '12'), + hasTypeError( + 'TypeError: Argument 1 for command <> has value "12", which ' + 'is not a boolean', + ), + ); + }); + + test('A two-argument command', () { + var value1 = double.nan; + var value2 = ''; + final storage = CommandStorage(); + storage.addCommand2('two', (double x, String y) { + value1 = x; + value2 = y; + }); + expect(storage.hasCommand('two'), true); + + void check(String args, double expectedValue1, String expectedValue2) { + storage.runCommand('two', args); + expect(value1, expectedValue1); + expect(value2, expectedValue2); + } + + check('1 2', 1.0, '2'); + check('2.12 Jenny', 2.12, 'Jenny'); + check('-0.001 "Yarn Spinner"', -0.001, 'Yarn Spinner'); + check(r'+3e+100 "\""', 3e100, '"'); + expect( + () => storage.runCommand('two', '2.0.3 error'), + hasTypeError( + 'TypeError: Argument 1 for command <> has value "2.0.3", which ' + 'is not a floating-point value', + ), + ); + }); + + test('A three-argument command', () { + num value1 = 0; + num value2 = 0; + num value3 = 0; + final storage = CommandStorage(); + storage.addCommand3('three', (num x, num y, num z) { + value1 = x; + value2 = y; + value3 = z; + }); + expect(storage.hasCommand('three'), true); + + void check(String args, num v1, num v2, num v3) { + storage.runCommand('three', args); + expect(value1, v1); + expect(value2, v2); + expect(value3, v3); + } + + check('1 2 3', 1, 2, 3); + check('1.1 2.2 3.3', 1.1, 2.2, 3.3); + check('Infinity -0.0 333', double.infinity, 0, 333); + expect( + () => storage.runCommand('three', '0 0 error'), + hasTypeError( + 'TypeError: Argument 3 for command <> has value "error", ' + 'which is not numeric', + ), + ); + }); + + test('Command with trailing booleans', () { + var value1 = false; + var value2 = false; + var value3 = false; + final storage = CommandStorage(); + storage.addCommand3('three', (bool x, bool y, bool z) { + value1 = x; + value2 = y; + value3 = z; + }); + expect(storage.hasCommand('three'), true); + + void check(String args, bool v1, bool v2, bool v3) { + storage.runCommand('three', args); + expect(value1, v1); + expect(value2, v2); + expect(value3, v3); + } + + check('true true true', true, true, true); + check('true true', true, true, false); + check('true', true, false, false); + check('', false, false, false); + }); + + group('errors', () { + test('Add a duplicate command', () { + final storage = CommandStorage(); + storage.addCommand0('foo', () => null); + expect( + () => storage.addCommand0('foo', () => null), + throwsAssertionError('Command <> has already been defined'), + ); + }); + + test('Add a built-in command', () { + for (final cmd in ['if', 'set', 'for', 'while', 'local']) { + expect( + () => CommandStorage().addCommand0(cmd, () => null), + throwsAssertionError('Command <<$cmd>> is built-in'), + ); + } + }); + + test('Bad command name', () { + for (final cmd in ['', '---', 'hello world', r'$fun']) { + expect( + () => CommandStorage().addCommand0(cmd, () => null), + throwsAssertionError('Command name "$cmd" is not an identifier'), + ); + } + }); + + test('Command with unsupported type', () { + expect( + () => CommandStorage().addCommand1('abc', (List x) => null), + throwsAssertionError('Unsupported type List of argument 1'), + ); + }); + + test('Wrong number of arguments', () { + final storage = CommandStorage() + ..addCommand2('xyz', (int z, bool f) => null); + expect( + () => storage.runCommand('xyz', '1 true 3'), + hasTypeError( + 'TypeError: Command <> expects 2 arguments but received 3 ' + 'arguments', + ), + ); + expect( + () => storage.runCommand('xyz', ''), + hasTypeError( + 'TypeError: Command <> expects 2 arguments but received 0 ' + 'arguments', + ), + ); + }); + }); + }); + + group('ArgumentsLexer', () { + List tokenize(String text) => ArgumentsLexer(text).tokenize(); + + test('empty string', () { + expect(tokenize(''), []); + expect(tokenize(' '), []); + expect(tokenize(' \t '), []); + }); + + test('single argument', () { + expect(tokenize('bob'), ['bob']); + expect(tokenize(' mary '), ['mary']); + expect(tokenize(' 1234.5'), ['1234.5']); + expect(tokenize('["Flame"]'), ['["Flame"]']); + }); + + test('quoted argument', () { + expect(tokenize('"Hello"'), ['Hello']); + expect(tokenize('"Hello World"'), ['Hello World']); + expect(tokenize(r' "Hel\"lo\" World\\"'), [r'Hel"lo" World\']); + expect(tokenize(r'"\n"'), ['\n']); + }); + + test('multiple arguments', () { + expect(tokenize('1 2 3 4 53'), ['1', '2', '3', '4', '53']); + expect(tokenize('Hello World'), ['Hello', 'World']); + expect(tokenize('flame \t awesome'), ['flame', 'awesome']); + expect(tokenize(' "one" "two" "three"'), ['one', 'two', 'three']); + }); + + group('errors', () { + test('unterminated quoted string', () { + expect( + () => tokenize('"hello'), + hasDialogueError('Unterminated quoted string'), + ); + }); + + test('unrecognized escape sequence', () { + expect( + () => tokenize(r'"hello \u1234 world"'), + hasDialogueError(r'Unrecognized escape sequence \u'), + ); + }); + + test('no space after a quoted string', () { + expect( + () => tokenize('"hello"next'), + hasDialogueError('Whitespace is required after a quoted argument'), + ); + }); + }); + }); +} diff --git a/packages/flame_jenny/jenny/test/dialogue_runner_test.dart b/packages/flame_jenny/jenny/test/dialogue_runner_test.dart index dc6c230cf81..9f3f6b16235 100644 --- a/packages/flame_jenny/jenny/test/dialogue_runner_test.dart +++ b/packages/flame_jenny/jenny/test/dialogue_runner_test.dart @@ -255,6 +255,8 @@ void main() { testScenario( testName: 'Compiler.plan', input: r''' + <> + title: Start --- // Compiler tests @@ -290,6 +292,7 @@ void main() { ''', testPlan: ''' line: This is a line! + command: this is a custom command line: Foo is 3! option: This is a shortcut option that you'll never see [disabled] option: This is a different shortcut option @@ -302,6 +305,7 @@ void main() { line: Cool. line: All done with the shortcut options! ''', + commands: ['this'], skip: true, ); }); diff --git a/packages/flame_jenny/jenny/test/parse/parse_test.dart b/packages/flame_jenny/jenny/test/parse/parse_test.dart index e34733ff9ba..63f7a59323e 100644 --- a/packages/flame_jenny/jenny/test/parse/parse_test.dart +++ b/packages/flame_jenny/jenny/test/parse/parse_test.dart @@ -696,7 +696,6 @@ void main() { expect((node.lines[0] as JumpCommand).target.value, 'UP'); expect((node.lines[1] as JumpCommand).target.value, 'DOWN'); }, - skip: true, ); }); }); diff --git a/packages/flame_jenny/jenny/test/parse/tokenize_test.dart b/packages/flame_jenny/jenny/test/parse/tokenize_test.dart index 35ca11e8fc2..34464910954 100644 --- a/packages/flame_jenny/jenny/test/parse/tokenize_test.dart +++ b/packages/flame_jenny/jenny/test/parse/tokenize_test.dart @@ -163,6 +163,7 @@ void main() { expect( tokenize( 'one: // comment\n' + '\n' 'two: some data //comment 2\n' '---\n===\n', ), @@ -172,6 +173,7 @@ void main() { Token.colon, Token.text(''), Token.newline, + Token.newline, Token.id('two'), Token.colon, Token.text('some data '), @@ -699,14 +701,22 @@ void main() { test('close command within a plain text expression', () { expect( - () => tokenize('---\n---\n' + tokenize('---\n---\n' '{ a >> b }\n' '===\n'), - hasSyntaxError( - 'SyntaxError: invalid token ">>" within an expression\n' - '> at line 3 column 5:\n' - '> { a >> b }\n' - '> ^\n'), + const [ + Token.startHeader, + Token.endHeader, + Token.startBody, + Token.startExpression, + Token.id('a'), + Token.operatorGreaterThan, + Token.operatorGreaterThan, + Token.id('b'), + Token.endExpression, + Token.newline, + Token.endBody, + ], ); }); @@ -768,8 +778,6 @@ void main() { Token.newline, Token.startCommand, Token.command('fullStop'), - Token.startExpression, - Token.endExpression, Token.endCommand, Token.newline, Token.startCommand, @@ -833,12 +841,31 @@ void main() { ); }); + test('user-defined commands', () { + expect( + tokenize('---\n---\n' + '<>\n' + '===\n'), + const [ + Token.startHeader, + Token.endHeader, + Token.startBody, + Token.startCommand, + Token.command('hello'), + Token.text('one two three'), + Token.endCommand, + Token.newline, + Token.endBody, + ], + ); + }); + test('closing brace', () { expect( () => tokenize('---\n---\n' '<< hello } >>\n' '===\n'), - hasSyntaxError('SyntaxError: invalid token "}" within a command\n' + hasSyntaxError('SyntaxError: invalid token\n' '> at line 3 column 10:\n' '> << hello } >>\n' '> ^\n'), @@ -858,7 +885,7 @@ void main() { }); }); - group('modeLineEnd', () { + group('modeTextEnd', () { test('hashtags in lines', () { expect( tokenize('---\n---\n' @@ -941,8 +968,6 @@ void main() { Token.hashtag('#one'), Token.startCommand, Token.command('two'), - Token.startExpression, - Token.endExpression, Token.endCommand, Token.startCommand, Token.commandStop, @@ -979,25 +1004,25 @@ void main() { test('long line, error near the start', () { expect( () => tokenize('---\n---\n' - '<< alpha beta gamma delta epsilon ~ zeta eta theta iota kappa ' - 'lambda mu nu xi omicron pi rho sigma tau >>\n' + '<>\n' '===\n'), hasSyntaxError('SyntaxError: invalid token\n' - '> at line 3 column 35:\n' - '> << alpha beta gamma delta epsilon ~ zeta eta theta iota ' - 'kappa lambda mu nu...\n' - '> ^\n'), + '> at line 3 column 38:\n' + '> < ^\n'), ); }); test('long line, error near the end', () { expect( () => tokenize('---\n---\n' - '<< alpha beta gamma delta epsilon zeta eta theta iota kappa ' + '<>\n' '===\n'), hasSyntaxError('SyntaxError: invalid token\n' - '> at line 3 column 92:\n' + '> at line 3 column 95:\n' '> ...theta iota kappa lambda mu nu xi omicron pi rho @ sigma ' 'tau upsilon phi chi>>\n' '> ^\n'), @@ -1007,12 +1032,12 @@ void main() { test('long line, error in the middle', () { expect( () => tokenize('---\n---\n' - '<< alpha beta gamma delta epsilon zeta eta theta iota kappa ' + '<>\n' '===\n'), hasSyntaxError('SyntaxError: invalid token\n' - '> at line 3 column 68:\n' + '> at line 3 column 71:\n' '> ...on zeta eta theta iota kappa lambda ` mu nu xi omicron ' 'pi rho sigma tau...\n' '> ^\n'), diff --git a/packages/flame_jenny/jenny/test/parse/utils.dart b/packages/flame_jenny/jenny/test/parse/utils.dart deleted file mode 100644 index ec444737f19..00000000000 --- a/packages/flame_jenny/jenny/test/parse/utils.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:jenny/src/errors.dart'; -import 'package:test/test.dart'; - -Matcher hasSyntaxError(String message) { - return throwsA( - isA().having((e) => e.toString(), 'toString', message), - ); -} - -Matcher hasNameError(String message) { - return throwsA( - isA().having((e) => e.toString(), 'toString', message), - ); -} - -Matcher hasTypeError(String message) { - return throwsA( - isA().having((e) => e.toString(), 'toString', message), - ); -} diff --git a/packages/flame_jenny/jenny/test/structure/commands/set_command_test.dart b/packages/flame_jenny/jenny/test/structure/commands/set_command_test.dart index b7048489291..6b8e8bd83ad 100644 --- a/packages/flame_jenny/jenny/test/structure/commands/set_command_test.dart +++ b/packages/flame_jenny/jenny/test/structure/commands/set_command_test.dart @@ -3,8 +3,8 @@ import 'package:jenny/src/parse/token.dart'; import 'package:jenny/src/parse/tokenize.dart'; import 'package:test/test.dart'; -import '../../parse/utils.dart'; import '../../test_scenario.dart'; +import '../../utils.dart'; void main() { group('SetCommand', () { diff --git a/packages/flame_jenny/jenny/test/structure/commands/user_defined_command_test.dart b/packages/flame_jenny/jenny/test/structure/commands/user_defined_command_test.dart index 1900a7918ac..9553eef7d5a 100644 --- a/packages/flame_jenny/jenny/test/structure/commands/user_defined_command_test.dart +++ b/packages/flame_jenny/jenny/test/structure/commands/user_defined_command_test.dart @@ -1,33 +1,98 @@ +import 'package:jenny/jenny.dart'; +import 'package:jenny/src/parse/token.dart'; +import 'package:jenny/src/parse/tokenize.dart'; +import 'package:jenny/src/structure/commands/user_defined_command.dart'; import 'package:test/test.dart'; import '../../test_scenario.dart'; +import '../../utils.dart'; void main() { group('UserDefinedCommand', () { + test('tokenization', () { + expect( + tokenize('---\n---\n' + '<>\n' + '==='), + const [ + Token.startHeader, + Token.endHeader, + Token.startBody, + Token.startCommand, + Token.command('hello'), + Token.text('world '), + Token.startExpression, + Token.variable(r'$exclamation'), + Token.endExpression, + Token.endCommand, + Token.newline, + Token.endBody, + ], + ); + }); + + test('parse simple dialogue command', () { + final project = YarnProject() + ..commands.addDialogueCommand('hello') + ..parse('title: start\n---\n' + '<>\n' + '==='); + expect(project.nodes['start']!.lines.length, 1); + expect(project.nodes['start']!.lines[0], isA()); + final cmd = project.nodes['start']!.lines[0] as UserDefinedCommand; + expect(cmd.name, 'hello'); + expect(cmd.argumentString.value, 'world AB'); + }); + + test('execute a live command', () { + var x = 0; + var y = ''; + final project = YarnProject() + ..commands.addCommand2('hello', (int a, String b) { + x = a; + y = b; + }) + ..parse('title: start\n---\n' + '<>\n' + '==='); + final runner = DialogueRunner(yarnProject: project, dialogueViews: []); + expect(project.nodes['start']!.lines.length, 1); + expect(project.nodes['start']!.lines[0], isA()); + final cmd = project.nodes['start']!.lines[0] as UserDefinedCommand; + expect(cmd.name, 'hello'); + cmd.execute(runner); + expect(x, 3); + expect(y, 'world'); + }); + + test('undeclared user-defined command', () { + expect( + () => YarnProject()..parse('title:A\n---\n<>\n===\n'), + hasNameError( + 'NameError: Unknown user-defined command <>\n' + '> at line 3 column 3:\n' + '> <>\n' + '> ^\n', + ), + ); + }); + testScenario( testName: 'Commands.yarn', input: ''' title: Start --- // Testing commands - <> // Commands that begin with keywords <> - <> - <> - - <> - + <> <> - - <> - + <> <> - <> // Commands with a single character @@ -42,15 +107,27 @@ void main() { command: toggle command: settings command: iffy - command: nulled + command: nullify command: orion - command: andorian + command: andromeda command: note command: isActive command: p command: hide Collision:GermOnPorch ''', - skip: true, + commands: [ + 'flip', + 'toggle', + 'settings', + 'iffy', + 'nullify', + 'orion', + 'andromeda', + 'note', + 'isActive', + 'p', + 'hide', + ], ); }); } diff --git a/packages/flame_jenny/jenny/test/test_scenario.dart b/packages/flame_jenny/jenny/test/test_scenario.dart index 5e5d6f09a62..f9ce1899eda 100644 --- a/packages/flame_jenny/jenny/test/test_scenario.dart +++ b/packages/flame_jenny/jenny/test/test_scenario.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:jenny/jenny.dart'; +import 'package:jenny/src/structure/commands/user_defined_command.dart'; import 'package:meta/meta.dart'; import 'package:test/test.dart'; @@ -16,11 +17,14 @@ Future testScenario({ required String input, required String testPlan, bool skip = false, + List? commands, }) async { test( testName, () async { - final yarn = YarnProject()..parse(_dedent(input)); + final yarn = YarnProject(); + commands?.forEach(yarn.commands.addDialogueCommand); + yarn.parse(_dedent(input)); final plan = _TestPlan(_dedent(testPlan)); final dialogue = DialogueRunner(yarnProject: yarn, dialogueViews: [plan]); await dialogue.runNode(plan.startNode); @@ -130,10 +134,12 @@ class _TestPlan extends DialogueView { final option2 = choice.options[i]; final text1 = (option1.character == null ? '' : '${option1.character}: ') + - option1.text; + option1.text + + (option1.enabled ? '' : ' [disabled]'); final text2 = (option2.character == null ? '' : '${option2.character}: ') + - option2.content.value; + option2.content.value + + (option2.available ? '' : ' [disabled]'); assert( text1 == text2, 'Expected (${i + 1}): $option1\n' @@ -149,12 +155,37 @@ class _TestPlan extends DialogueView { return expected.selectionIndex - 1; } + @override + void onCommand(UserDefinedCommand command) { + assert( + !done, + 'Expected: END OF DIALOGUE\n' + 'Actual : $command', + ); + assert( + nextEntry is _Command, + 'Wrong event at test plan index $_currentIndex\n' + 'Expected: "$nextEntry"\n' + 'Actual : "$command"\n', + ); + final expected = nextEntry as _Command; + final text1 = '<<${expected.name} ${expected.content}>>'; + final text2 = '<<${command.name} ${command.argumentString.value}>>'; + assert( + text1 == text2, + 'Expected line: "$text1"\n' + 'Actual line : "$text2"\n', + ); + _currentIndex++; + } + void _parse(String input) { final rxEmpty = RegExp(r'^\s*$'); final rxLine = RegExp(r'^line:\s+((\w+):\s+)?(.*)$'); final rxOption = RegExp(r'^option:\s+((\w+):\s+)?(.*?)\s*(\[disabled\])?$'); final rxSelect = RegExp(r'^select:\s+(\d+)$'); final rxRun = RegExp(r'^run: (.*)$'); + final rxCommand = RegExp(r'command: (\w+)(?:\s+(.*))?$'); final lines = const LineSplitter().convert(input); for (var i = 0; i < lines.length; i++) { @@ -164,6 +195,7 @@ class _TestPlan extends DialogueView { final match2 = rxOption.firstMatch(line); final match3 = rxSelect.firstMatch(line); final match4 = rxRun.firstMatch(line); + final match5 = rxCommand.firstMatch(line); if (match0 != null) { continue; } else if (match1 != null) { @@ -184,6 +216,8 @@ class _TestPlan extends DialogueView { _expected.add(_Choice(options, index)); } else if (match4 != null) { startNode = match4.group(1)!; + } else if (match5 != null) { + _expected.add(_Command(match5.group(1)!, match5.group(2) ?? '')); } else { throw 'Unrecognized test plan line $i: "$line"'; } @@ -218,3 +252,11 @@ class _Option { @override String toString() => 'Option($character: $text [$enabled])'; } + +class _Command { + const _Command(this.name, this.content); + final String name; + final String content; + @override + String toString() => 'Command($name, "$content")'; +} diff --git a/packages/flame_jenny/jenny/test/utils.dart b/packages/flame_jenny/jenny/test/utils.dart index be5353e3c2d..f9eb138ee20 100644 --- a/packages/flame_jenny/jenny/test/utils.dart +++ b/packages/flame_jenny/jenny/test/utils.dart @@ -24,3 +24,9 @@ Matcher hasDialogueError(String message) { isA().having((e) => e.message, 'message', message), ); } + +Matcher throwsAssertionError(String message) { + return throwsA( + isA().having((e) => e.message, 'message', message), + ); +}