diff --git a/doc/_sphinx/extensions/yarn_lexer.py b/doc/_sphinx/extensions/yarn_lexer.py index 0d6364f6cb0..8981cadc9ba 100644 --- a/doc/_sphinx/extensions/yarn_lexer.py +++ b/doc/_sphinx/extensions/yarn_lexer.py @@ -152,7 +152,7 @@ class YarnLexer(RegexLexer): (r'\$\w+', Name.Variable), (r'([+\-*/%><=]=?)', Operator), (r'\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?', Number), - (r'[(),]', Punctuation), + (r'[(),]|\.\.\.', Punctuation), (r'"', String.Delimeter, 'string'), (r'\w+', Name.Function), (r'.', Error), diff --git a/doc/other_modules/jenny/language/commands/character.md b/doc/other_modules/jenny/language/commands/character.md new file mode 100644 index 00000000000..57c0f2f141c --- /dev/null +++ b/doc/other_modules/jenny/language/commands/character.md @@ -0,0 +1,60 @@ +# `<>` + +The **\<\\>** command declares a character with the given name, and one or more aliases +that can be used in the scripts. + +The command has several purposes: + +- it protects you from accidentally misspelling a character's name in your script; +- it allows a character to have *full name*, which doesn't have to be an ID; +- it allows declaring multiple aliases for the same character, which can be used in different + nodes (an alias may even be in a different language than the full name); +- you can associate additional data with each character, which will then be available at runtime. + +The format of this command is the following: + +```yarn +<> +``` + +The *full name* here is optional: if given, it will be considered *the* name of the character. +However, if the name is omitted, then the first alias will be considered the true character's name. +Each *alias* must be a valid ID, and at least one alias must be provided. For example: + +```yarn +// A well-mannered seven-year-old girl, who nevertheless always gets into +// all kinds of zany adventures. +<> + +// A magical cat known for his ability to grin majestically, and partially +// vanish. He is mad (by his own admission). +<> + +// A foul-tempered Queen, who is also a playing card. Described as +// "a blind fury", her favorite saying is "Off with their heads!". +// Not to be confused with Red Queen. +<> +``` + +After a character is declared, any of its aliases can be used in the script: they will all refer +to the same `Character` object. At the same time, using a character without declaring it first is +not allowed (unless a special flag in `YarnProject` is set to allow this). + +```yarn +title: Alice_and_the_Cat +--- +Alice: But I don't want to go among mad people. +Cat: Oh, you can't help that, we're all mad here. I'm mad. You're mad. +Alice: How do you know I'm mad? +Cat: You must be, or you wouldn't have come here. +Alice: And how do you know that you're mad? +Cat: To begin with, a dog's not mad. You grant that? +Alice: I suppose so. +Cat: Well then, you see a dog growls when it's angry, and wags its tail \ + when it's pleased. +Cat: Now, [i]I[/i] growl when I'm pleased, and wag my tail when I'm angry. \ + Therefore, I'm mad. +Alice: [i]I[/i] call it purring, not growling. +Cat: Call it what you like. +=== +``` diff --git a/doc/other_modules/jenny/language/commands/commands.md b/doc/other_modules/jenny/language/commands/commands.md index dab95c499be..9e42494cdcb 100644 --- a/doc/other_modules/jenny/language/commands/commands.md +++ b/doc/other_modules/jenny/language/commands/commands.md @@ -16,6 +16,9 @@ scripts. For a full description of these commands, see the document on [user-def ### Variables +**[\<\\>](character.md)** +: Declares a character (person). + **[\<\\>](declare.md)** : Declares a global variable. @@ -50,6 +53,7 @@ scripts. For a full description of these commands, see the document on [user-def ```{toctree} :hidden: +<> <> <> <> diff --git a/doc/other_modules/jenny/language/lines.md b/doc/other_modules/jenny/language/lines.md index 1e0768bde86..1c8df45aecd 100644 --- a/doc/other_modules/jenny/language/lines.md +++ b/doc/other_modules/jenny/language/lines.md @@ -15,9 +15,6 @@ in a [node body]. A line may contain the following elements: A **line** is represented with the [DialogueLine] class in Jenny runtime. -[node body]: nodes.md#body -[DialogueLine]: ../runtime/dialogue_line.md - ## Character ID @@ -62,6 +59,11 @@ Attention\: The cake is NOT a lie === ``` +```{note} +All characters must be **declared** using the [\<\\>] command +before they can be used in a script. +``` + ## Interpolated expressions @@ -101,7 +103,7 @@ further processing. Which means that the text of the expression may contain spec (such as `[`, `]`, `{`, `}`, `\`, etc), and they don't need to be escaped. It also means that the expression cannot contain markup, or produce a hashtag, etc. -Read more about expressions in the [Expressions](expressions/expressions.md) section. +Read more about expressions in the [Expressions] section. ## Markup @@ -122,7 +124,7 @@ additional information attached to the line that shows that the last 17 characte the `em` tag. Markup tags can be nested, or be zero-width, they can also include parameters whose values can be -dynamic. Read more about this in the [Markup](markup.md) document. +dynamic. Read more about this in the [Markup] document. ## Hashtags @@ -184,3 +186,9 @@ This line is so long that it becomes uncomfortable to read in a text editor. \ text. === ``` + + +[node body]: nodes.md#body +[DialogueLine]: ../runtime/dialogue_line.md +[Expressions]: expressions/expressions.md +[Markup]: markup.md diff --git a/doc/other_modules/jenny/runtime/character.md b/doc/other_modules/jenny/runtime/character.md new file mode 100644 index 00000000000..873fe85ffa4 --- /dev/null +++ b/doc/other_modules/jenny/runtime/character.md @@ -0,0 +1,29 @@ +# Character + +A **Character** represents a person who is speaking a particular line in a dialogue. This object +is available as the `.character` property of a [DialogueLine] delivered to your [DialogueView]. + + +## Properties + +**name** `String` +: The canonical name of the character, as declared by the [\<\\>] command. + +**aliases** `List` +: Additional names (IDs) that may be used for this character in yarn scripts. + +**data** `Map` +: Extra information that you can associate with this character. This may include their short bio, + portrait, affiliation, color, etc. This information must be stored for each character manually, + and then it will be accessible from [DialogueView]s. + + +## See Also + +- [CharacterStorage]: the container where all Character objects within a YarnProject are cached. + + +[\<\\>]: ../language/commands/character.md +[CharacterStorage]: character_storage.md +[DialogueView]: dialogue_view.md +[DialogueLine]: dialogue_line.md diff --git a/doc/other_modules/jenny/runtime/character_storage.md b/doc/other_modules/jenny/runtime/character_storage.md new file mode 100644 index 00000000000..3f7d1e4fe38 --- /dev/null +++ b/doc/other_modules/jenny/runtime/character_storage.md @@ -0,0 +1,22 @@ +# CharacterStorage + +The **CharacterStorage** object is a cache of all [Character]s declared in yarn scripts. Typically, +this cache will be populated with the help of the [\<\\>] commands. Adding characters +manually is possible but not recommended. + + +## Methods + +**contains**(`String name`) → `bool` +: Returns `true` if a character with the given name or alias was defined. + +**operator[]**(`String name`) → `Character?` +: Returns the [Character] object with the given name/alias, or `null` if this character was not + defined. + +**add**(`Character character`) +: Adds a new `Character` object into the storage. + + +[\<\\>]: ../language/commands/character.md +[Character]: character.md diff --git a/doc/other_modules/jenny/runtime/dialogue_line.md b/doc/other_modules/jenny/runtime/dialogue_line.md index b71b3c15227..cccd6d70237 100644 --- a/doc/other_modules/jenny/runtime/dialogue_line.md +++ b/doc/other_modules/jenny/runtime/dialogue_line.md @@ -7,7 +7,7 @@ The **DialogueLine** class represents a single [Line] of text in the `.yarn` scr ## Properties -**character** `String?` +**character** `Character?` : The name of the character who is speaking the line, or `null` if the line has no speaker. **text** `String` diff --git a/doc/other_modules/jenny/runtime/dialogue_option.md b/doc/other_modules/jenny/runtime/dialogue_option.md index 163f29e67d0..9f0778cab2a 100644 --- a/doc/other_modules/jenny/runtime/dialogue_option.md +++ b/doc/other_modules/jenny/runtime/dialogue_option.md @@ -25,5 +25,6 @@ options will be grouped into [DialogueChoice] objects. **isDisabled** `bool` : Same as `!isAvailable`. + [Option]: ../language/options.md [DialogueChoice]: dialogue_choice.md diff --git a/doc/other_modules/jenny/runtime/index.md b/doc/other_modules/jenny/runtime/index.md index 6b6aaf9cc03..1b8c7298c2a 100644 --- a/doc/other_modules/jenny/runtime/index.md +++ b/doc/other_modules/jenny/runtime/index.md @@ -3,6 +3,8 @@ ```{toctree} :hidden: +Character +CharacterStorage CommandStorage DialogueChoice DialogueLine diff --git a/doc/other_modules/jenny/runtime/yarn_project.md b/doc/other_modules/jenny/runtime/yarn_project.md index 62158b763ee..e894d48a335 100644 --- a/doc/other_modules/jenny/runtime/yarn_project.md +++ b/doc/other_modules/jenny/runtime/yarn_project.md @@ -63,6 +63,15 @@ final yarn = YarnProject() All custom commands must be added before they can be used in the dialogue script. +**characters** `CharacterStorage` +: The [container][CharacterStorage] for all [Character] objects declared in your yarn scripts. + +**strictCharacterNames** `bool` +: If `true` (default), the validity of character names will be strictly enforced. That is, all + characters must be declared before they can be used, using the [\<\\>] commands. If + this property is set to false, then new [Character] objects will be created automatically as + they are encountered in scripts. + **trueValues**, **falseValues** `Set` : The strings that can be recognized as `true`/`false` values respectively. @@ -77,6 +86,9 @@ final yarn = YarnProject() existing ones. +[\<\\>]: ../language/commands/character.md +[Character]: character.md +[CharacterStorage]: character_storage.md [CommandStorage]: command_storage.md [FunctionStorage]: function_storage.md [Node]: node.md diff --git a/packages/flame_jenny/jenny/lib/jenny.dart b/packages/flame_jenny/jenny/lib/jenny.dart index 9227e781c1f..adef818b296 100644 --- a/packages/flame_jenny/jenny/lib/jenny.dart +++ b/packages/flame_jenny/jenny/lib/jenny.dart @@ -1,3 +1,5 @@ +export 'src/character.dart' show Character; +export 'src/character_storage.dart' show CharacterStorage; export 'src/command_storage.dart' show CommandStorage; export 'src/dialogue_runner.dart' show DialogueRunner; export 'src/dialogue_view.dart' show DialogueView; diff --git a/packages/flame_jenny/jenny/lib/src/character.dart b/packages/flame_jenny/jenny/lib/src/character.dart new file mode 100644 index 00000000000..55527982b7c --- /dev/null +++ b/packages/flame_jenny/jenny/lib/src/character.dart @@ -0,0 +1,22 @@ +/// A [Character] represents a particular person who is speaking a line in a +/// dialogue. All characters must be declared with the help of the +/// `<>` command. +class Character { + Character(this.name, {List? aliases}) : aliases = aliases ?? []; + + Map? _data; + + /// The canonical name of the character + final String name; + + /// The list of aliases + final List aliases; + + /// Additional information associated with this character. + /// + /// You can store any key-value pairs here that you want, or nothing at all. + Map get data => _data ??= {}; + + @override + String toString() => 'Character($name)'; +} diff --git a/packages/flame_jenny/jenny/lib/src/character_storage.dart b/packages/flame_jenny/jenny/lib/src/character_storage.dart new file mode 100644 index 00000000000..0f60566c4ec --- /dev/null +++ b/packages/flame_jenny/jenny/lib/src/character_storage.dart @@ -0,0 +1,28 @@ +import 'package:jenny/src/character.dart'; + +/// The container for all [Character]s defined in your yarn scripts. This +/// container is populated as the YarnProject parses the input scripts. +class CharacterStorage { + final Map _cache = {}; + + bool get isEmpty => _cache.isEmpty; + bool get isNotEmpty => _cache.isNotEmpty; + + /// Was the character with the given name or alias added to this container? + bool contains(String name) => _cache.containsKey(name); + + /// Retrieves the character given its name/alias, or returns `null` if such + /// character is not present. + Character? operator [](String name) => _cache[name]; + + /// Adds a new [character] to the container. + /// + /// This is mostly intended for internal use; in yarn scripts use command + /// `<>` to declare characters. + void add(Character character) { + _cache[character.name] = character; + for (final alias in character.aliases) { + _cache[alias] = character; + } + } +} diff --git a/packages/flame_jenny/jenny/lib/src/parse/parse.dart b/packages/flame_jenny/jenny/lib/src/parse/parse.dart index 10be350ef1a..b3d3ece9f6f 100644 --- a/packages/flame_jenny/jenny/lib/src/parse/parse.dart +++ b/packages/flame_jenny/jenny/lib/src/parse/parse.dart @@ -1,7 +1,8 @@ -import 'package:jenny/src/errors.dart'; +import 'package:jenny/jenny.dart'; import 'package:jenny/src/parse/token.dart'; import 'package:jenny/src/parse/tokenize.dart'; import 'package:jenny/src/structure/block.dart'; +import 'package:jenny/src/structure/commands/character_command.dart'; import 'package:jenny/src/structure/commands/command.dart'; import 'package:jenny/src/structure/commands/declare_command.dart'; import 'package:jenny/src/structure/commands/if_command.dart'; @@ -9,13 +10,9 @@ import 'package:jenny/src/structure/commands/jump_command.dart'; import 'package:jenny/src/structure/commands/local_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/visit_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_entry.dart'; -import 'package:jenny/src/structure/dialogue_line.dart'; -import 'package:jenny/src/structure/dialogue_option.dart'; import 'package:jenny/src/structure/expressions/expression.dart'; import 'package:jenny/src/structure/expressions/functions/_common.dart'; import 'package:jenny/src/structure/expressions/functions/string.dart'; @@ -25,10 +22,6 @@ import 'package:jenny/src/structure/expressions/operators/_common.dart' import 'package:jenny/src/structure/expressions/operators/negate.dart'; import 'package:jenny/src/structure/expressions/operators/not.dart'; import 'package:jenny/src/structure/line_content.dart'; -import 'package:jenny/src/structure/markup_attribute.dart'; -import 'package:jenny/src/structure/node.dart'; -import 'package:jenny/src/variable_storage.dart'; -import 'package:jenny/src/yarn_project.dart'; import 'package:meta/meta.dart'; @internal @@ -54,7 +47,7 @@ class _Parser { if (token == Token.startCommand) { final position0 = position; final command = parseCommand(); - if (command is! DeclareCommand) { + if (command is! DeclareCommand && command is! CharacterCommand) { position = position0; typeError('command <<${command.name}>> is only allowed inside nodes'); } @@ -146,9 +139,11 @@ class _Parser { } else if (nextToken == Token.startCommand) { final position0 = position; final command = parseCommand(); - if (command is DeclareCommand) { - position = position0; - syntaxError('<> command cannot be used inside a node'); + if (command is DeclareCommand || command is CharacterCommand) { + syntaxError( + '<<${command.name}>> command cannot be used inside a node', + position0, + ); } lines.add(command); } else if (nextToken.isText || @@ -209,12 +204,16 @@ class _Parser { ); } - String? maybeParseLinePerson() { + Character? maybeParseLinePerson() { final token = peekToken(); if (token.isPerson) { takePerson(); take(Token.colon); - return token.content; + final name = token.content; + if (project.strictCharacterNames && !project.characters.contains(name)) { + nameError('unknown character "$name"', position - 2); + } + return project.characters[name] ?? Character(name); } return null; } @@ -411,6 +410,8 @@ class _Parser { return parseCommandSet(); } else if (token == Token.commandDeclare || token == Token.commandLocal) { return parseCommandDeclareOrLocal(); + } else if (token == Token.commandCharacter) { + return parseCommandCharacter(); } else if (token == Token.commandElseif || token == Token.commandElse || token == Token.commandEndif) { @@ -675,6 +676,40 @@ class _Parser { } } + Command parseCommandCharacter() { + take(Token.startCommand); + take(Token.commandCharacter); + take(Token.startExpression); + String? realName; + if (peekToken().isString) { + realName = peekToken().content; + position += 1; + } + final aliases = []; + while (peekToken().isId) { + final alias = peekToken().content; + if (project.characters.contains(alias)) { + final char = project.characters[alias]!; + nameError('character "$alias" was already defined: $char'); + } + aliases.add(alias); + position += 1; + } + take(Token.endExpression); + if (aliases.isEmpty) { + syntaxError('at least one character id is required'); + } + if (realName == null) { + realName = aliases.first; + aliases.removeAt(0); + } + take(Token.endCommand); + takeNewline(); + final character = Character(realName, aliases: aliases); + project.characters.add(character); + return const CharacterCommand(); + } + Command parseUserDefinedCommand() { take(Token.startCommand); final commandToken = peekToken(); diff --git a/packages/flame_jenny/jenny/lib/src/parse/token.dart b/packages/flame_jenny/jenny/lib/src/parse/token.dart index 787399d77a1..d2d30a13257 100644 --- a/packages/flame_jenny/jenny/lib/src/parse/token.dart +++ b/packages/flame_jenny/jenny/lib/src/parse/token.dart @@ -21,6 +21,7 @@ class Token { static const closeMarkupTag = Token._(TokenType.closeMarkupTag); static const colon = Token._(TokenType.colon); static const comma = Token._(TokenType.comma); + static const commandCharacter = Token._(TokenType.commandCharacter); static const commandDeclare = Token._(TokenType.commandDeclare); static const commandElse = Token._(TokenType.commandElse); static const commandElseif = Token._(TokenType.commandElseif); @@ -121,6 +122,7 @@ enum TokenType { closeMarkupTag, // '/' (e.g. in "[br/]") colon, // ':' comma, // ',' + commandCharacter, // 'character' commandDeclare, // 'declare' commandElse, // 'else' commandElseif, // 'elseif' diff --git a/packages/flame_jenny/jenny/lib/src/parse/tokenize.dart b/packages/flame_jenny/jenny/lib/src/parse/tokenize.dart index 522bdbc0a45..e029fdc47a2 100644 --- a/packages/flame_jenny/jenny/lib/src/parse/tokenize.dart +++ b/packages/flame_jenny/jenny/lib/src/parse/tokenize.dart @@ -980,6 +980,7 @@ class _Lexer { ')': Token.endParenthesis, }; static const Map commandTokens = { + 'character': Token.commandCharacter, 'declare': Token.commandDeclare, 'else': Token.commandElse, 'elseif': Token.commandElseif, @@ -1002,6 +1003,7 @@ class _Lexer { /// Built-in commands that are followed by an expression (without `{}`). static final Set bareExpressionCommands = { + Token.commandCharacter, Token.commandDeclare, Token.commandElseif, Token.commandIf, diff --git a/packages/flame_jenny/jenny/lib/src/structure/commands/character_command.dart b/packages/flame_jenny/jenny/lib/src/structure/commands/character_command.dart new file mode 100644 index 00000000000..72da10b3459 --- /dev/null +++ b/packages/flame_jenny/jenny/lib/src/structure/commands/character_command.dart @@ -0,0 +1,12 @@ +import 'package:jenny/src/dialogue_runner.dart'; +import 'package:jenny/src/structure/commands/command.dart'; + +class CharacterCommand extends Command { + const CharacterCommand(); + + @override + String get name => 'character'; + + @override + void execute(DialogueRunner dialogue) => throw AssertionError(); +} diff --git a/packages/flame_jenny/jenny/lib/src/structure/dialogue_line.dart b/packages/flame_jenny/jenny/lib/src/structure/dialogue_line.dart index e216593539f..df614ade653 100644 --- a/packages/flame_jenny/jenny/lib/src/structure/dialogue_line.dart +++ b/packages/flame_jenny/jenny/lib/src/structure/dialogue_line.dart @@ -39,21 +39,21 @@ import 'package:jenny/src/structure/line_content.dart'; class DialogueLine extends DialogueEntry { DialogueLine({ required LineContent content, - String? character, + Character? character, List? tags, }) : _content = content, _character = character, _tags = tags, _value = content.isConst ? content.text : null; - final String? _character; + final Character? _character; final List? _tags; final LineContent _content; String? _value; - /// The name of the character who is speaking the line. This can be null if - /// the line does not contain a speaker. - String? get character => _character; + /// The character who is speaking the line. This can be null if the line does + /// not contain a speaker. + Character? get character => _character; /// The computed text of the line, after substituting all inline expressions. /// @@ -88,7 +88,7 @@ class DialogueLine extends DialogueEntry { @override String toString() { - final prefix = character == null ? '' : '$character: '; + final prefix = character == null ? '' : '${character!.name}: '; final text = _value ?? ''; return 'DialogueLine($prefix$text)'; } diff --git a/packages/flame_jenny/jenny/lib/src/structure/dialogue_option.dart b/packages/flame_jenny/jenny/lib/src/structure/dialogue_option.dart index bdff10d74c8..b5225a76b10 100644 --- a/packages/flame_jenny/jenny/lib/src/structure/dialogue_option.dart +++ b/packages/flame_jenny/jenny/lib/src/structure/dialogue_option.dart @@ -26,7 +26,7 @@ class DialogueOption extends DialogueLine { @override String toString() { - final prefix = character == null ? '' : '$character: '; + final prefix = character == null ? '' : '${character!.name}: '; final suffix = _available ? '' : ' #disabled'; return 'Option($prefix$text$suffix)'; } diff --git a/packages/flame_jenny/jenny/lib/src/yarn_project.dart b/packages/flame_jenny/jenny/lib/src/yarn_project.dart index 21a8a1a9a76..a87ce828993 100644 --- a/packages/flame_jenny/jenny/lib/src/yarn_project.dart +++ b/packages/flame_jenny/jenny/lib/src/yarn_project.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:jenny/src/character_storage.dart'; import 'package:jenny/src/command_storage.dart'; import 'package:jenny/src/errors.dart'; import 'package:jenny/src/function_storage.dart'; @@ -23,6 +24,7 @@ class YarnProject { variables = VariableStorage(), functions = FunctionStorage(), commands = CommandStorage(), + characters = CharacterStorage(), random = Random() { locale = 'en'; } @@ -32,6 +34,8 @@ class YarnProject { /// All parsed [Node]s, keyed by their titles. final Map nodes; + /// All global variables accessible within the yarn scripts are stored here. + /// In addition, this also keeps information about node visit counts. final VariableStorage variables; /// User-defined functions are stored here. @@ -40,6 +44,10 @@ class YarnProject { /// Repository for user-defined commands. final CommandStorage commands; + final CharacterStorage characters; + + bool strictCharacterNames = true; + /// 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'}; diff --git a/packages/flame_jenny/jenny/test/dialogue_runner_test.dart b/packages/flame_jenny/jenny/test/dialogue_runner_test.dart index 69f28bf125f..9f5229700a3 100644 --- a/packages/flame_jenny/jenny/test/dialogue_runner_test.dart +++ b/packages/flame_jenny/jenny/test/dialogue_runner_test.dart @@ -10,6 +10,7 @@ void main() { group('DialogueRunner', () { test('plain dialogue', () async { final yarn = YarnProject() + ..strictCharacterNames = false ..parse( '-------------\n' 'title: Hamlet\n' @@ -65,6 +66,7 @@ void main() { test('DialogueViews with delays', () async { final yarn = YarnProject() + ..strictCharacterNames = false ..parse('title: The Robot and the Mattress\n' '---\n' 'Zem: Hello, robot\n' diff --git a/packages/flame_jenny/jenny/test/parse/parse_test.dart b/packages/flame_jenny/jenny/test/parse/parse_test.dart index dbb1d3c17de..5276f201038 100644 --- a/packages/flame_jenny/jenny/test/parse/parse_test.dart +++ b/packages/flame_jenny/jenny/test/parse/parse_test.dart @@ -205,10 +205,12 @@ void main() { group('parseDialogueLine', () { test('line with a speaker', () { final yarn = YarnProject() + ..strictCharacterNames = false ..parse('title:A\n---\nMrGoo: whatever\n===\n'); expect(yarn.nodes['A']!.lines.first, isA()); final line = yarn.nodes['A']!.lines[0] as DialogueLine; - expect(line.character, 'MrGoo'); + expect(line.character, isA()); + expect(line.character!.name, 'MrGoo'); expect(line.text, 'whatever'); }); @@ -290,6 +292,7 @@ void main() { test('speakers in options', () { final yarn = YarnProject() + ..strictCharacterNames = false ..parse('title:A\n---\n' '-> Alice: Hello!\n' '-> Bob: Hi: there!\n' @@ -298,8 +301,9 @@ void main() { final choice = node.lines[0] as DialogueChoice; final option0 = choice.options[0]; final option1 = choice.options[1]; - expect(option0.character, 'Alice'); - expect(option1.character, 'Bob'); + expect(option0.character, isA()); + expect(option0.character!.name, 'Alice'); + expect(option1.character!.name, 'Bob'); expect(option0.text, 'Hello!'); expect(option1.text, 'Hi: there!'); }); @@ -648,11 +652,13 @@ void main() { test('parse nested tags', () { final yarn = YarnProject() + ..strictCharacterNames = false ..parse('title: A\n---\n' 'Warning: [a]Spinning [b][c]Je[/c]nny[/b][/a]\n' '===\n'); final line = yarn.nodes['A']!.lines[0] as DialogueLine; - expect(line.character, 'Warning'); + expect(line.character, isA()); + expect(line.character!.name, 'Warning'); expect(line.text, 'Spinning Jenny'); expect(line.attributes.length, 3); diff --git a/packages/flame_jenny/jenny/test/parse/token_test.dart b/packages/flame_jenny/jenny/test/parse/token_test.dart index cd9b2c52e29..f8ea93c0c58 100644 --- a/packages/flame_jenny/jenny/test/parse/token_test.dart +++ b/packages/flame_jenny/jenny/test/parse/token_test.dart @@ -7,6 +7,7 @@ void main() { expect('${Token.asType}', 'Token.asType'); expect('${Token.closeMarkupTag}', 'Token.closeMarkupTag'); expect('${Token.colon}', 'Token.colon'); + expect('${Token.commandCharacter}', 'Token.commandCharacter'); expect('${Token.commandEndif}', 'Token.commandEndif'); expect('${Token.commandLocal}', 'Token.commandLocal'); expect('${Token.commandVisit}', 'Token.commandVisit'); diff --git a/packages/flame_jenny/jenny/test/structure/commands/character_command_test.dart b/packages/flame_jenny/jenny/test/structure/commands/character_command_test.dart new file mode 100644 index 00000000000..b619509e0df --- /dev/null +++ b/packages/flame_jenny/jenny/test/structure/commands/character_command_test.dart @@ -0,0 +1,177 @@ +import 'package:jenny/src/parse/token.dart'; +import 'package:jenny/src/parse/tokenize.dart'; +import 'package:jenny/src/yarn_project.dart'; +import 'package:test/test.dart'; + +import '../../test_scenario.dart'; +import '../../utils.dart'; + +void main() { + group('CharacterCommand', () { + test('tokenize <> command', () { + expect( + tokenize('<>'), + const [ + Token.startCommand, + Token.commandCharacter, + Token.startExpression, + Token.string('Agent Smith'), + Token.id('Smith'), + Token.id('AgSmith'), + Token.endExpression, + Token.endCommand, + ], + ); + }); + + test('<> command declares new characters', () { + final yarn = YarnProject(); + yarn.parse( + '<>\n' + '<>\n' + '<>\n' + '<>\n', + ); + expect(yarn.characters.isEmpty, false); + expect(yarn.characters.isNotEmpty, true); + + expect(yarn.characters.contains('HP'), true); + expect(yarn.characters.contains('HG'), true); + expect(yarn.characters.contains('RW'), true); + expect(yarn.characters.contains('HW'), false); + expect(yarn.characters.contains('Hermione'), true); + expect(yarn.characters.contains('Peeves'), true); + expect(yarn.characters.contains('Harry'), true); + expect(yarn.characters.contains('Ron'), true); + expect(yarn.characters.contains('harry'), false); + + final harry = yarn.characters['Harry']!; + expect(harry.name, 'Harry Potter'); + expect(harry.aliases, ['Harry', 'HP']); + expect(harry.data, isEmpty); + harry.data['age'] = 11; + harry.data['affiliation'] = 'Dumbledore'; + expect(harry.data['age'], 11); + expect(harry.data['affiliation'], 'Dumbledore'); + + final peeves = yarn.characters['Peeves']!; + expect(peeves.name, 'Peeves'); + expect(peeves.aliases, isEmpty); + + expect(yarn.characters['Hermione'], isNotNull); + expect(yarn.characters['Voldemort'], isNull); + }); + + test('script with strict characters', () async { + await testScenario( + input: ''' + <> + <> + --- + title: Start + --- + Alice: Cheshire Puss, would you tell me which way to go from here? + Cat: That depends a great deal on where you want to get to + Alice: I don't much care where -- + Cat: Then it doesn't matter which way you go + Alice: so long as I get [i]somewhere[/i] + Cat: Oh, you're sure to do that, if you only walk long enough + === + ''', + testPlan: ''' + line: Alice: Cheshire Puss, would you tell me which way to go from here? + line: Cat: That depends a great deal on where you want to get to + line: Alice: I don't much care where -- + line: Cat: Then it doesn't matter which way you go + line: Alice: so long as I get somewhere + line: Cat: Oh, you're sure to do that, if you only walk long enough + ''', + ); + }); + + test('script without character declared', () { + expect( + () => YarnProject() + ..parse( + 'title: Start\n' + '---\n' + 'Alice: Do cats eat bats?\n' + '===\n', + ), + hasNameError( + 'NameError: unknown character "Alice"\n' + '> at line 3 column 1:\n' + '> Alice: Do cats eat bats?\n' + '> ^\n', + ), + ); + }); + + group('errors', () { + test('invalid syntax', () { + expect( + () => YarnProject()..parse(r'<>'), + hasSyntaxError('SyntaxError: unexpected token\n' + '> at line 1 column 18:\n' + '> <>\n' + '> ^\n'), + ); + }); + + test('no character name or ids', () { + expect( + () => YarnProject()..parse('<>'), + hasSyntaxError('SyntaxError: at least one character id is required\n' + '> at line 1 column 12:\n' + '> <>\n' + '> ^\n'), + ); + }); + + test('only character name', () { + expect( + () => YarnProject()..parse('<>'), + hasSyntaxError('SyntaxError: at least one character id is required\n' + '> at line 1 column 19:\n' + '> <>\n' + '> ^\n'), + ); + }); + + test('duplicate character id', () { + expect( + () => YarnProject() + ..parse( + '<>\n' + '<>\n', + ), + hasNameError( + 'NameError: character "bob" was already defined: Character(Fancy ' + 'Bob)\n' + '> at line 2 column 32:\n' + '> <>\n' + '> ^\n', + ), + ); + }); + + test('character command inside a node', () { + expect( + () => YarnProject() + ..parse( + 'title: X\n' + '---\n' + '<>\n' + '===\n', + ), + hasSyntaxError( + 'SyntaxError: <> command cannot be used inside a node\n' + '> at line 3 column 1:\n' + '> <>\n' + '> ^\n', + ), + ); + }); + }); + }); +} 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 1c32a3b4cdb..a3fe8e8c382 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 @@ -64,7 +64,7 @@ void main() { <> haha nice now 'set' works even when deeply nested <> - aaargh :( + aaargh >:( <> === ''', diff --git a/packages/flame_jenny/jenny/test/structure/dialogue_line_test.dart b/packages/flame_jenny/jenny/test/structure/dialogue_line_test.dart index 2b1e190a78b..89684ef8ed8 100644 --- a/packages/flame_jenny/jenny/test/structure/dialogue_line_test.dart +++ b/packages/flame_jenny/jenny/test/structure/dialogue_line_test.dart @@ -16,12 +16,12 @@ void main() { test('line with meta information', () { final line = DialogueLine( - character: 'Bob', + character: Character('Bob'), content: LineContent('Hello!'), tags: ['#red', '#fast'], ); expect(line.text, 'Hello!'); - expect(line.character, 'Bob'); + expect(line.character!.name, 'Bob'); expect(line.tags, ['#red', '#fast']); expect('$line', 'DialogueLine(Bob: Hello!)'); }); diff --git a/packages/flame_jenny/jenny/test/structure/dialogue_option_test.dart b/packages/flame_jenny/jenny/test/structure/dialogue_option_test.dart index ccc05b50b05..4557d783079 100644 --- a/packages/flame_jenny/jenny/test/structure/dialogue_option_test.dart +++ b/packages/flame_jenny/jenny/test/structure/dialogue_option_test.dart @@ -1,4 +1,4 @@ -import 'package:jenny/src/structure/dialogue_option.dart'; +import 'package:jenny/jenny.dart'; import 'package:jenny/src/structure/expressions/literal.dart'; import 'package:jenny/src/structure/line_content.dart'; import 'package:test/test.dart'; @@ -21,11 +21,11 @@ void main() { test('simple option', () { final option = DialogueOption( content: LineContent('me'), - character: 'Rook', + character: Character('Rook'), condition: constFalse, ); option.evaluate(); - expect(option.character, 'Rook'); + expect(option.character!.name, 'Rook'); expect(option.tags, isEmpty); expect(option.attributes, isEmpty); expect(option.text, 'me'); diff --git a/packages/flame_jenny/jenny/test/test_scenario.dart b/packages/flame_jenny/jenny/test/test_scenario.dart index 959222040c3..99e619fd43f 100644 --- a/packages/flame_jenny/jenny/test/test_scenario.dart +++ b/packages/flame_jenny/jenny/test/test_scenario.dart @@ -19,7 +19,8 @@ Future testScenario({ List? commands, YarnProject? yarn, }) async { - final yarnProject = yarn ?? YarnProject(); + final yarnProject = yarn ?? YarnProject() + ..strictCharacterNames = false; commands?.forEach(yarnProject.commands.addOrphanedCommand); Future testBody() async { @@ -107,7 +108,7 @@ class _TestPlan extends DialogueView { : '${expected.character}: ${expected.text}'; final text2 = (line.character == null) ? line.text - : '${line.character}: ${line.text}'; + : '${line.character!.name}: ${line.text}'; assert( text1 == text2, 'Expected line: "$text1"\n' @@ -144,7 +145,7 @@ class _TestPlan extends DialogueView { option1.text + (option1.enabled ? '' : ' [disabled]'); final text2 = - (option2.character == null ? '' : '${option2.character}: ') + + (option2.character == null ? '' : '${option2.character!.name}: ') + option2.text + (option2.isAvailable ? '' : ' [disabled]'); assert(