diff --git a/scripts/tw-make b/scripts/tw-make index 736751b2..fbebe80a 100755 --- a/scripts/tw-make +++ b/scripts/tw-make @@ -3,8 +3,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT license. - +import os import argparse +from os.path import join as pjoin + import numpy as np import textworld @@ -14,8 +16,8 @@ def parse_args(): general_parser = argparse.ArgumentParser(add_help=False) general_group = general_parser.add_argument_group('General settings') - general_group.add_argument("--output", default="./gen_games/", metavar="PATH", - help="Output folder to save generated game files.") + general_group.add_argument("--output", default="./tw_games/", metavar="PATH", + help="Path where to save the generated game.") general_group.add_argument('--seed', type=int) general_group.add_argument("--view", action="store_true", help="Display the resulting game.") @@ -64,18 +66,21 @@ if __name__ == "__main__": print("Global seed: {}".format(args.seed)) - grammar_flags = { - "theme": args.theme, - "include_adj": args.include_adj, - "only_last_action": args.only_last_action, - "blend_instructions": args.blend_instructions, - "blend_descriptions": args.blend_descriptions, - "ambiguous_instructions": args.ambiguous_instructions, - } + options = textworld.GameOptions() + options.grammar.theme = args.theme + options.grammar.include_adj = args.include_adj + options.grammar.only_last_action = args.only_last_action + options.grammar.blend_instructions = args.blend_instructions + options.grammar.blend_descriptions = args.blend_descriptions + options.grammar.ambiguous_instructions = args.ambiguous_instructions if args.subcommand == "custom": - game_file, game = textworld.make(args.world_size, args.nb_objects, args.quest_length, args.quest_breadth, grammar_flags, - seed=args.seed, games_dir=args.output) + options.nb_rooms = args.world_size + options.nb_objects = args.nb_objects + options.quest_length = args.quest_length + options.quest_breadth = args.quest_breadth + options.seeds = args.seed + game_file, game = textworld.make(options, args.output) elif args.subcommand == "challenge": _, challenge, level = args.challenge.split("-") @@ -84,8 +89,8 @@ if __name__ == "__main__": level = int(level.lstrip("level")) make_game = textworld.challenges.CHALLENGES[challenge] - game = make_game(level=level, grammar_flags=grammar_flags, seeds=args.seed) - game_file = textworld.generator.compile_game(game, games_folder=args.output) + game = make_game(level, options) + game_file = textworld.generator.compile_game(game, args.output) print("Game generated: {}".format(game_file)) if args.verbose: diff --git a/scripts_dev/benchmark_framework.py b/scripts_dev/benchmark_framework.py index f82adff8..15960446 100644 --- a/scripts_dev/benchmark_framework.py +++ b/scripts_dev/benchmark_framework.py @@ -1,28 +1,40 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT license. - import time import argparse +from os.path import join as pjoin + +import numpy as np import textworld from textworld import g_rng from textworld.generator import World +from textworld.generator.game import GameOptions + def generate_never_ending_game(args): g_rng.set_seed(args.seed) + msg = "--max-steps {} --nb-objects {} --nb-rooms {} --quest-length {} --quest-breadth {} --seed {}" print(msg.format(args.max_steps, args.nb_objects, args.nb_rooms, args.quest_length, args.quest_breadth, g_rng.seed)) print("Generating game...") - grammar_flags = {} - game = textworld.generator.make_game(args.nb_rooms, args.nb_objects, args.quest_length, args.quest_breadth, grammar_flags) + options = GameOptions() + options.seeds = g_rng.seed + options.nb_rooms = args.nb_rooms + options.nb_objects = args.nb_objects + options.quest_length = args.quest_length + options.quest_breadth = args.quest_breadth + + game = textworld.generator.make_game(options) if args.no_quest: game.quests = [] game_name = "neverending" - game_file = textworld.generator.compile_game(game, game_name, force_recompile=True, games_folder=args.output) + path = pjoin(args.output, game_name + ".ulx") + game_file = textworld.generator.compile_game(game, path, force_recompile=True) return game_file @@ -33,7 +45,7 @@ def benchmark(game_file, args): if args.mode == "random": agent = textworld.agents.NaiveAgent() elif args.mode == "random-cmd": - agent = textworld.agents.RandomCommandAgent() + agent = textworld.agents.RandomCommandAgent(seed=args.agent_seed) elif args.mode == "walkthrough": agent = textworld.agents.WalkthroughAgent() @@ -52,14 +64,13 @@ def benchmark(game_file, args): reward = 0 done = False - print("Benchmarking {} using ...".format(game_file, env.__class__.__name__)) start_time = time.time() for _ in range(args.max_steps): command = agent.act(game_state, reward, done) game_state, reward, done = env.step(command) if done: - print("Win! Reset.") + #print("Win! Reset.") env.reset() done = False @@ -69,6 +80,7 @@ def benchmark(game_file, args): duration = time.time() - start_time speed = args.max_steps / duration print("Done {:,} steps in {:.2f} secs ({:,.1f} steps/sec)".format(args.max_steps, duration, speed)) + return speed def parse_args(): @@ -90,6 +102,7 @@ def parse_args(): parser.add_argument("--compute_intermediate_reward", action="store_true") parser.add_argument("--activate_state_tracking", action="store_true") parser.add_argument("--seed", type=int) + parser.add_argument("--agent-seed", type=int, default=2018) parser.add_argument("-v", "--verbose", action="store_true") return parser.parse_args() @@ -97,4 +110,13 @@ def parse_args(): if __name__ == "__main__": args = parse_args() game_file = generate_never_ending_game(args) - benchmark(game_file, args) + + + speeds = [] + for _ in range(10): + speed = benchmark(game_file, args) + speeds.append(speed) + args.agent_seed = args.agent_seed + 1 + + print("-----\nAverage: {:,.1f} steps/sec".format(np.mean(speeds))) + diff --git a/tests/test_make_game.py b/tests/test_make_game.py index b8b9be69..e24115b9 100644 --- a/tests/test_make_game.py +++ b/tests/test_make_game.py @@ -11,21 +11,34 @@ def test_making_game_with_names_to_exclude(): g_rng.set_seed(42) with make_temp_directory(prefix="test_render_wrapper") as tmpdir: - game_file1, game1 = textworld.make(2, 20, 3, 3, {"names_to_exclude": []}, - seed=123, games_dir=tmpdir) - + options = textworld.GameOptions() + options.nb_rooms = 2 + options.nb_objects = 20 + options.quest_length = 3 + options.quest_breadth = 3 + options.seeds = 123 + game_file1, game1 = textworld.make(options, path=tmpdir) + + options2 = options.copy() game1_objects_names = [info.name for info in game1.infos.values() if info.name is not None] - game_file2, game2 = textworld.make(2, 20, 3, 3, {"names_to_exclude": game1_objects_names}, - seed=123, games_dir=tmpdir) + options2.grammar.names_to_exclude = game1_objects_names + game_file2, game2 = textworld.make(options2, path=tmpdir) game2_objects_names = [info.name for info in game2.infos.values() if info.name is not None] assert len(set(game1_objects_names) & set(game2_objects_names)) == 0 def test_making_game_is_reproducible_with_seed(): - grammar_flags = {} with make_temp_directory(prefix="test_render_wrapper") as tmpdir: - game_file1, game1 = textworld.make(2, 20, 3, 3, grammar_flags, seed=123, games_dir=tmpdir) - game_file2, game2 = textworld.make(2, 20, 3, 3, grammar_flags, seed=123, games_dir=tmpdir) + options = textworld.GameOptions() + options.nb_rooms = 2 + options.nb_objects = 20 + options.quest_length = 3 + options.quest_breadth = 3 + options.seeds = 123 + + game_file1, game1 = textworld.make(options, path=tmpdir) + options2 = options.copy() + game_file2, game2 = textworld.make(options2, path=tmpdir) assert game_file1 == game_file2 assert game1 == game2 # Make sure they are not the same Python objects. diff --git a/tests/test_play_generated_games.py b/tests/test_play_generated_games.py index 0fd050c5..52e44939 100644 --- a/tests/test_play_generated_games.py +++ b/tests/test_play_generated_games.py @@ -19,11 +19,15 @@ def test_play_generated_games(): quest_length = rng.randint(2, 5) quest_breadth = rng.randint(3, 7) game_seed = rng.randint(0, 65365) - grammar_flags = {} # Default grammar. with make_temp_directory(prefix="test_play_generated_games") as tmpdir: - game_file, game = textworld.make(world_size, nb_objects, quest_length, quest_breadth, grammar_flags, - seed=game_seed, games_dir=tmpdir) + options = textworld.GameOptions() + options.nb_rooms = world_size + options.nb_objects = nb_objects + options.quest_length = quest_length + options.quest_breadth = quest_breadth + options.seeds = game_seed + game_file, game = textworld.make(options, path=tmpdir) # Solve the game using WalkthroughAgent. agent = textworld.agents.WalkthroughAgent() diff --git a/tests/test_textworld.py b/tests/test_textworld.py index 2dd5499f..d321b9fa 100644 --- a/tests/test_textworld.py +++ b/tests/test_textworld.py @@ -14,9 +14,12 @@ class TestIntegration(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp(prefix="test_textworld") - self.game_file, self.game = textworld.make(world_size=5, nb_objects=10, - quest_length=10, grammar_flags={}, - seed=1234, games_dir=self.tmpdir) + options = textworld.GameOptions() + options.nb_rooms = 5 + options.nb_objects = 10 + options.quest_length = 10 + options.seeds = 1234 + self.game_file, self.game = textworld.make(options, path=self.tmpdir) def tearDown(self): shutil.rmtree(self.tmpdir) diff --git a/tests/test_tw-make.py b/tests/test_tw-make.py index 2fdc1963..f89481b8 100644 --- a/tests/test_tw-make.py +++ b/tests/test_tw-make.py @@ -2,6 +2,7 @@ # Licensed under the MIT license. import os +import glob from subprocess import check_call from os.path import join as pjoin @@ -11,15 +12,51 @@ def test_making_a_custom_game(): - with make_temp_directory(prefix="test_tw-make") as tmpdir: + with make_temp_directory(prefix="test_tw-make") as tmpdir: output_folder = pjoin(tmpdir, "gen_games") - command = ["tw-make", "custom", "--seed", "1234", "--output", output_folder] + game_file = pjoin(output_folder, "game_1234.ulx") + command = ["tw-make", "custom", "--seed", "1234", "--output", game_file] assert check_call(command) == 0 assert os.path.isdir(output_folder) - game_file = pjoin(output_folder, "game_1234.ulx") assert os.path.isfile(game_file) # Solve the game using WalkthroughAgent. agent = textworld.agents.WalkthroughAgent() textworld.play(game_file, agent=agent, silent=True) + + with make_temp_directory(prefix="test_tw-make") as tmpdir: + output_folder = pjoin(tmpdir, "gen_games") + game_file = pjoin(output_folder, "game_1234") # Default extension is .ulx + command = ["tw-make", "custom", "--seed", "1234", "--output", game_file] + assert check_call(command) == 0 + + assert os.path.isdir(output_folder) + assert os.path.isfile(game_file + ".ulx") + + # Solve the game using WalkthroughAgent. + agent = textworld.agents.WalkthroughAgent() + textworld.play(game_file + ".ulx", agent=agent, silent=True) + + with make_temp_directory(prefix="test_tw-make") as tmpdir: + output_folder = pjoin(tmpdir, "gen_games", "") + command = ["tw-make", "custom", "--seed", "1234", "--output", output_folder] + assert check_call(command) == 0 + + assert os.path.isdir(output_folder) + game_file = glob.glob(pjoin(output_folder, "*.ulx"))[0] + + # Solve the game using WalkthroughAgent. + agent = textworld.agents.WalkthroughAgent() + textworld.play(game_file, agent=agent, silent=True) + + with make_temp_directory(prefix="test_tw-make") as tmpdir: + output_folder = pjoin(tmpdir, "gen_games") + command = ["tw-make", "custom", "--seed", "1234", "--output", output_folder] + assert check_call(command) == 0 + + assert os.path.isfile(output_folder + ".ulx") + + # Solve the game using WalkthroughAgent. + agent = textworld.agents.WalkthroughAgent() + textworld.play(output_folder + ".ulx", agent=agent, silent=True) diff --git a/tests/test_tw-play.py b/tests/test_tw-play.py index adc305b1..d5ad003b 100644 --- a/tests/test_tw-play.py +++ b/tests/test_tw-play.py @@ -9,7 +9,13 @@ def test_playing_a_game(): with make_temp_directory(prefix="test_tw-play") as tmpdir: - game_file, _ = textworld.make(5, 10, 5, 4, {}, seed=1234, games_dir=tmpdir) + options = textworld.GameOptions() + options.nb_rooms = 5 + options.nb_objects = 10 + options.quest_length = 5 + options.quest_breadth = 4 + options.seeds = 1234 + game_file, _ = textworld.make(options, path=tmpdir) command = ["tw-play", "--max-steps", "100", "--mode", "random", game_file] assert check_call(command) == 0 diff --git a/textworld/__init__.py b/textworld/__init__.py index 562a708a..4a240c06 100644 --- a/textworld/__init__.py +++ b/textworld/__init__.py @@ -6,7 +6,7 @@ from textworld.utils import g_rng from textworld.core import Environment, GameState, Agent -from textworld.generator import Game, GameMaker +from textworld.generator import Game, GameMaker, GameOptions from textworld.generator import TextworldGenerationWarning diff --git a/textworld/challenges/coin_collector.py b/textworld/challenges/coin_collector.py index 669ed19f..e8a752b0 100644 --- a/textworld/challenges/coin_collector.py +++ b/textworld/challenges/coin_collector.py @@ -24,37 +24,19 @@ from textworld.generator.graph_networks import reverse_direction from textworld.utils import encode_seeds -from textworld.generator.text_grammar import encode_flags - +from textworld.generator.game import GameOptions from textworld.challenges.utils import get_seeds_for_game_generation -def make_game_from_level(level: int, - grammar_flags: Mapping = {}, - seeds: Optional[Union[int, Dict[str, int]]] = None - ) -> textworld.Game: +def make_game_from_level(level: int, options: Optional[GameOptions] = None) -> textworld.Game: """ Make a Coin Collector game of the desired difficulty level. Arguments: level: Difficulty level (see notes). - grammar_flags: Options for the grammar controlling the text generation - process. - seeds: Seeds for the different generation processes. - - * If `None`, seeds will be sampled from - :py:data:`textworld.g_rng `. - * If `int`, it acts as a seed for a random generator that will - be used to sample the other seeds. - * If dict, the following keys can be set: - - * `'seed_map'`: control the map generation; - * `'seed_objects'`: control the type of objects and their - location; - * `'seed_quest'`: control the quest generation; - * `'seed_grammar'`: control the text generation. - - For any key missing, a random number gets assigned (sampled - from :py:data:`textworld.g_rng `). + options: + For customizing the game generation (see + :py:class:`textworld.GameOptions ` + for the list of available options). Returns: Generated game. @@ -70,16 +52,13 @@ def make_game_from_level(level: int, * ... """ n_distractors = (level // 100) - quest_length = level % 100 - n_rooms = (n_distractors + 1) * quest_length + options.quest_length = level % 100 + options.nb_rooms = (n_distractors + 1) * options.quest_length distractor_mode = "random" if n_distractors > 2 else "simple" - return make_game(distractor_mode, n_rooms, quest_length, grammar_flags, seeds) + return make_game(distractor_mode, options) -def make_game(mode: str, n_rooms: int, quest_length: int, - grammar_flags: Mapping = {}, - seeds: Optional[Union[int, Dict[str, int]]] = None - ) -> textworld.Game: +def make_game(mode: str, options: GameOptions) -> textworld.Game: """ Make a Coin Collector game. Arguments: @@ -91,57 +70,38 @@ def make_game(mode: str, n_rooms: int, quest_length: int, * `'random'`: the distractor rooms are randomly place along the chain. This means a player can wander for a while before reaching a dead end. - n_rooms: Number of rooms in the game. - quest_length: Number of rooms in the chain. This also represents the - number of commands for the optimal policy. - grammar_flags: Options for the grammar controlling the text generation - process. - seeds: Seeds for the different generation processes. - - * If `None`, seeds will be sampled from - :py:data:`textworld.g_rng `. - * If `int`, it acts as a seed for a random generator that will be - used to sample the other seeds. - * If dict, the following keys can be set: - - * `'seed_map'`: control the map generation; - * `'seed_objects'`: control the type of objects and their - location; - * `'seed_quest'`: control the quest generation; - * `'seed_grammar'`: control the text generation. - - For any key missing, a random number gets assigned (sampled - from :py:data:`textworld.g_rng `). + options: + For customizing the game generation (see + :py:class:`textworld.GameOptions ` + for the list of available options). Returns: Generated game. """ - if mode == "simple" and float(n_rooms) / quest_length > 4: + if mode == "simple" and float(options.nb_rooms) / options.quest_length > 4: msg = ("Total number of rooms must be less than 4 * `quest_length` " "when distractor mode is 'simple'.") raise ValueError(msg) - # Deal with any missing random seeds. - seeds = get_seeds_for_game_generation(seeds) - metadata = {} # Collect infos for reproducibility. metadata["desc"] = "Coin Collector" metadata["mode"] = mode - metadata["seeds"] = seeds - metadata["world_size"] = n_rooms - metadata["quest_length"] = quest_length - metadata["grammar_flags"] = grammar_flags + metadata["seeds"] = options.seeds + metadata["world_size"] = options.nb_rooms + metadata["quest_length"] = options.quest_length + metadata["grammar_flags"] = options.grammar.encode() - rng_map = np.random.RandomState(seeds['seed_map']) - rng_grammar = np.random.RandomState(seeds['seed_grammar']) + rngs = options.rngs + rng_map = rngs['map'] + rng_grammar = rngs['grammar'] # Generate map. M = textworld.GameMaker() - M.grammar = textworld.generator.make_grammar(flags=grammar_flags, rng=rng_grammar) + M.grammar = textworld.generator.make_grammar(options.grammar, rng=rng_grammar) rooms = [] walkthrough = [] - for i in range(quest_length): + for i in range(options.quest_length): r = M.new_room() if i >= 1: # Connect it to the previous rooms. @@ -186,9 +146,9 @@ def make_game(mode: str, n_rooms: int, quest_length: int, game = M.build() game.metadata = metadata mode_choice = 0 if mode == "simple" else 1 - uuid = "tw-coin_collector-{specs}-{flags}-{seeds}" - uuid = uuid.format(specs=encode_seeds((mode_choice, n_rooms, quest_length)), - flags=encode_flags(grammar_flags), - seeds=encode_seeds([seeds[k] for k in sorted(seeds)])) + uuid = "tw-coin_collector-{specs}-{grammar}-{seeds}" + uuid = uuid.format(specs=encode_seeds((mode_choice, options.nb_rooms, options.quest_length)), + grammar=options.grammar.uuid, + seeds=encode_seeds([options.seeds[k] for k in sorted(options.seeds)])) game.metadata["uuid"] = uuid return game diff --git a/textworld/challenges/treasure_hunter.py b/textworld/challenges/treasure_hunter.py index 5fe115a8..890efa9d 100644 --- a/textworld/challenges/treasure_hunter.py +++ b/textworld/challenges/treasure_hunter.py @@ -34,35 +34,18 @@ from textworld.challenges.utils import get_seeds_for_game_generation from textworld.utils import encode_seeds -from textworld.generator.text_grammar import encode_flags +from textworld.generator.game import GameOptions -def make_game_from_level(level: int, - grammar_flags: Mapping = {}, - seeds: Optional[Union[int, Dict[str, int]]] = None - ) -> textworld.Game: +def make_game_from_level(level: int, options: Optional[GameOptions] = None) -> textworld.Game: """ Make a Treasure Hunter game of the desired difficulty level. Arguments: level: Difficulty level (see notes). - grammar_flags: Options for the grammar controlling the text generation - process. - seeds: Seeds for the different generation processes. - - * If `None`, seeds will be sampled from - :py:data:`textworld.g_rng `. - * If `int`, it acts as a seed for a random generator that will be - used to sample the other seeds. - * If dict, the following keys can be set: - - * `'seed_map'`: control the map generation; - * `'seed_objects'`: control the type of objects and their - location; - * `'seed_quest'`: control the quest generation; - * `'seed_grammar'`: control the text generation. - - For any key missing, a random number gets assigned (sampled - from :py:data:`textworld.g_rng `). + options: + For customizing the game generation (see + :py:class:`textworld.GameOptions ` + for the list of available options). Returns: game: Generated game. @@ -87,29 +70,28 @@ def make_game_from_level(level: int, the inventory) that might need to be unlocked (and open) in order to find the object. """ + options = options or GameOptions() + if level >= 21: mode = "hard" - n_rooms = 20 + options.nb_rooms = 20 quest_lengths = np.round(np.linspace(3, 20, 10)) - quest_length = int(quest_lengths[level - 21]) + options.quest_length = int(quest_lengths[level - 21]) elif level >= 11: mode = "medium" - n_rooms = 10 + options.nb_rooms = 10 quest_lengths = np.round(np.linspace(2, 10, 10)) - quest_length = int(quest_lengths[level - 11]) + options.quest_length = int(quest_lengths[level - 11]) elif level >= 1: mode = "easy" - n_rooms = 5 + options.nb_rooms = 5 quest_lengths = np.round(np.linspace(1, 5, 10)) - quest_length = int(quest_lengths[level - 1]) + options.quest_length = int(quest_lengths[level - 1]) - return make_game(mode, n_rooms, quest_length, grammar_flags, seeds) + return make_game(mode, options) -def make_game(mode: str, n_rooms: int, quest_length: int, - grammar_flags: Mapping = {}, - seeds: Optional[Union[int, Dict[str, int]]] = None - ) -> textworld.Game: +def make_game(mode: str, options: GameOptions) -> textworld.Game: """ Make a Treasure Hunter game. Arguments: @@ -122,46 +104,27 @@ def make_game(mode: str, n_rooms: int, quest_length: int, * `'hard'`: adding locked doors and containers (necessary keys will in the inventory) that might need to be unlocked (and open) in order to find the object. - n_rooms: Number of rooms in the game. - quest_length: How far from the player the object to find should ideally - be placed. - grammar_flags: Options for the grammar controlling the text generation - process. - seeds: Seeds for the different generation processes. - - * If `None`, seeds will be sampled from - :py:data:`textworld.g_rng `. - * If `int`, it acts as a seed for a random generator that will be - used to sample the other seeds. - * If dict, the following keys can be set: - - * `'seed_map'`: control the map generation; - * `'seed_objects'`: control the type of objects and their - location; - * `'seed_quest'`: control the quest generation; - * `'seed_grammar'`: control the text generation. - - For any key missing, a random number gets assigned (sampled - from :py:data:`textworld.g_rng `). + options: + For customizing the game generation (see + :py:class:`textworld.GameOptions ` + for the list of available options). Returns: Generated game. """ - # Deal with any missing random seeds. - seeds = get_seeds_for_game_generation(seeds) - metadata = {} # Collect infos for reproducibility. metadata["desc"] = "Treasure Hunter" metadata["mode"] = mode - metadata["seeds"] = seeds - metadata["world_size"] = n_rooms - metadata["quest_length"] = quest_length - metadata["grammar_flags"] = grammar_flags + metadata["seeds"] = options.seeds + metadata["world_size"] = options.nb_rooms + metadata["quest_length"] = options.quest_length + metadata["grammar_flags"] = options.grammar.encode() - rng_map = np.random.RandomState(seeds['seed_map']) - rng_objects = np.random.RandomState(seeds['seed_objects']) - rng_quest = np.random.RandomState(seeds['seed_quest']) - rng_grammar = np.random.RandomState(seeds['seed_grammar']) + rngs = options.rngs + rng_map = rngs['seed_map'] + rng_objects = rngs['seed_objects'] + rng_quest = rngs['seed_quest'] + rng_grammar = rngs['seed_grammar'] modes = ["easy", "medium", "hard"] if mode == "easy": @@ -175,9 +138,9 @@ def make_game(mode: str, n_rooms: int, quest_length: int, n_distractors = 20 # Generate map. - map_ = textworld.generator.make_map(n_rooms=n_rooms, rng=rng_map, + map_ = textworld.generator.make_map(n_rooms=options.nb_rooms, rng=rng_map, possible_door_states=door_states) - assert len(map_.nodes()) == n_rooms + assert len(map_.nodes()) == options.nb_rooms world = World.from_map(map_) @@ -220,29 +183,25 @@ def make_game(mode: str, n_rooms: int, quest_length: int, # Generate a quest that finishes by taking something (i.e. the right # object since it's the only one in the inventory). - rules_per_depth = {0: data.get_rules().get_matching("take.*")} - exceptions = ["r", "c", "s", "d"] if mode == "easy" else ["r"] - chain = textworld.generator.sample_quest(world.state, rng_quest, - max_depth=quest_length, - allow_partial_match=False, - exceptions=exceptions, - rules_per_depth=rules_per_depth, - nb_retry=5, - backward=True) + options.chaining.rules_per_depth = [data.get_rules().get_matching("take.*")] + options.chaining.backward = True + options.chaining.rng = rng_quest + #options.chaining.restricted_types = exceptions + #exceptions = ["r", "c", "s", "d"] if mode == "easy" else ["r"] + chain = textworld.generator.sample_quest(world.state, options.chaining) # Add objects needed for the quest. world.state = chain[0].state quest = Quest([c.action for c in chain]) quest.set_failing_conditions([Proposition("in", [wrong_obj, world.inventory])]) - grammar = textworld.generator.make_grammar(flags=grammar_flags, - rng=rng_grammar) + grammar = textworld.generator.make_grammar(options.grammar, rng=rng_grammar) game = textworld.generator.make_game_with(world, [quest], grammar) game.metadata = metadata mode_choice = modes.index(mode) - uuid = "tw-treasure_hunter-{specs}-{flags}-{seeds}" - uuid = uuid.format(specs=encode_seeds((mode_choice, n_rooms, quest_length)), - flags=encode_flags(grammar_flags), - seeds=encode_seeds([seeds[k] for k in sorted(seeds)])) + uuid = "tw-treasure_hunter-{specs}-{grammar}-{seeds}" + uuid = uuid.format(specs=encode_seeds((mode_choice, options.nb_rooms, options.quest_length)), + grammar=options.grammar.uuid, + seeds=encode_seeds([options.seeds[k] for k in sorted(options.seeds)])) game.metadata["uuid"] = uuid return game diff --git a/textworld/envs/glulx/tests/test_git_glulx_ml.py b/textworld/envs/glulx/tests/test_git_glulx_ml.py index 5e1d706e..8809fffd 100644 --- a/textworld/envs/glulx/tests/test_git_glulx_ml.py +++ b/textworld/envs/glulx/tests/test_git_glulx_ml.py @@ -12,6 +12,7 @@ import numpy.testing as npt import textworld +from textworld import g_rng from textworld import testing from textworld.generator.maker import GameMaker @@ -68,11 +69,12 @@ def compile_game(game, folder): "instruction_extension": [] } rng_grammar = np.random.RandomState(1234) - grammar = textworld.generator.make_grammar(flags=grammar_flags, rng=rng_grammar) + grammar = textworld.generator.make_grammar(grammar_flags, rng=rng_grammar) game.change_grammar(grammar) game_name = "test_game" - game_file = textworld.generator.compile_game(game, game_name, games_folder=folder) + path = pjoin(folder, game_name + ".ulx") + game_file = textworld.generator.compile_game(game, path) return game_file @@ -80,6 +82,7 @@ class TestGlulxGameState(unittest.TestCase): @classmethod def setUpClass(cls): + g_rng.set_seed(201809) cls.game = build_test_game() cls.tmpdir = tempfile.mkdtemp() cls.game_file = compile_game(cls.game, folder=cls.tmpdir) @@ -178,7 +181,7 @@ def test_game_ended_when_no_quest(self): game = M.build() game_name = "test_game_ended_when_no_quest" with make_temp_directory(prefix=game_name) as tmpdir: - game_file = textworld.generator.compile_game(game, game_name, games_folder=tmpdir) + game_file = textworld.generator.compile_game(game, path=tmpdir) env = textworld.start(game_file) env.activate_state_tracking() diff --git a/textworld/envs/wrappers/tests/test_viewer.py b/textworld/envs/wrappers/tests/test_viewer.py index 32a4c4fe..4bfe2a9b 100644 --- a/textworld/envs/wrappers/tests/test_viewer.py +++ b/textworld/envs/wrappers/tests/test_viewer.py @@ -4,7 +4,6 @@ import textworld - from textworld import g_rng from textworld.utils import make_temp_directory, get_webdriver from textworld.generator import compile_game @@ -15,13 +14,18 @@ def test_html_viewer(): # Integration test for visualization service num_nodes = 3 num_items = 10 - g_rng.set_seed(1234) - grammar_flags = {"theme": "house", "include_adj": True} - game = textworld.generator.make_game(world_size=num_nodes, nb_objects=num_items, quest_length=3, quest_breadth=1, grammar_flags=grammar_flags) + options = textworld.GameOptions() + options.seeds = 1234 + options.nb_rooms = num_nodes + options.nb_objects = num_items + options.quest_length = 3 + options.grammar.theme = "house" + options.grammar.include_adj = True + game = textworld.generator.make_game(options) game_name = "test_html_viewer_wrapper" with make_temp_directory(prefix=game_name) as tmpdir: - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) env = textworld.start(game_file) env = HtmlViewer(env, open_automatically=False, port=8080) diff --git a/textworld/generator/__init__.py b/textworld/generator/__init__.py index 8496cece..28085531 100644 --- a/textworld/generator/__init__.py +++ b/textworld/generator/__init__.py @@ -4,6 +4,7 @@ import os import json +import uuid import numpy as np from os.path import join as pjoin from typing import Optional, Mapping, Dict @@ -13,7 +14,7 @@ from textworld import g_rng from textworld.utils import maybe_mkdir, str2bool from textworld.generator.chaining import ChainingOptions, sample_quest -from textworld.generator.game import Game, Quest, World +from textworld.generator.game import Game, Quest, World, GameOptions from textworld.generator.graph_networks import create_map, create_small_map from textworld.generator.text_generation import generate_text_from_grammar @@ -21,7 +22,7 @@ from textworld.generator.inform7 import generate_inform7_source, compile_inform7_game from textworld.generator.inform7 import CouldNotCompileGameError -from textworld.generator.data import load_data +from textworld.generator.data import load_data, get_rules from textworld.generator.text_grammar import Grammar from textworld.generator.maker import GameMaker from textworld.generator.logger import GameLogger @@ -85,13 +86,13 @@ def make_world(world_size, nb_objects=0, rngs=None): if rngs is None: rngs = {} rng = g_rng.next() - rngs['rng_map'] = RandomState(rng.randint(65635)) - rngs['rng_objects'] = RandomState(rng.randint(65635)) + rngs['map'] = RandomState(rng.randint(65635)) + rngs['objects'] = RandomState(rng.randint(65635)) - map_ = make_map(n_rooms=world_size, rng=rngs['rng_map']) + map_ = make_map(n_rooms=world_size, rng=rngs['map']) world = World.from_map(map_) world.set_player_room() - world.populate(nb_objects=nb_objects, rng=rngs['rng_objects']) + world.populate(nb_objects=nb_objects, rng=rngs['objects']) return world @@ -129,9 +130,9 @@ def make_quest(world, quest_length, rng=None, rules_per_depth=(), backward=False return Quest(chain.actions) -def make_grammar(flags: Mapping = {}, rng: Optional[RandomState] = None) -> Grammar: +def make_grammar(options: Mapping = {}, rng: Optional[RandomState] = None) -> Grammar: rng = g_rng.next() if rng is None else rng - grammar = Grammar(flags, rng) + grammar = Grammar(options, rng) grammar.check() return grammar @@ -147,54 +148,32 @@ def make_game_with(world, quests=None, grammar=None): return game -def make_game(world_size: int, nb_objects: int, quest_length: int, quest_breadth: int, - grammar_flags: Mapping = {}, - rngs: Optional[Dict[str, RandomState]] = None - ) -> Game: +def make_game(options: GameOptions) -> Game: """ Make a game (map + objects + quest). Arguments: - world_size: Number of rooms in the world. - nb_objects: Number of objects in the world. - quest_length: Minimum nb. of actions the quest requires to be completed. - quest_breadth: How many branches the quest can have. - grammar_flags: Options for the grammar. + options: + For customizing the game generation (see + :py:class:`textworld.GameOptions ` + for the list of available options). Returns: Generated game. """ - if rngs is None: - rngs = {} - rng = g_rng.next() - rngs['rng_map'] = RandomState(rng.randint(65635)) - rngs['rng_objects'] = RandomState(rng.randint(65635)) - rngs['rng_quest'] = RandomState(rng.randint(65635)) - rngs['rng_grammar'] = RandomState(rng.randint(65635)) + rngs = options.rngs # Generate only the map for now (i.e. without any objects) - world = make_world(world_size, nb_objects=0, rngs=rngs) + world = make_world(options.nb_rooms, nb_objects=0, rngs=rngs) - # Sample a quest according to quest_length. - class Options(ChainingOptions): - - def get_rules(self, depth): - if depth == 0: - # Last action should not be "go ". - return data.get_rules().get_matching("^(?!go.*).*") - else: - return super().get_rules(depth) - - options = Options() - options.backward = True - options.min_depth = 1 - options.max_depth = quest_length - options.min_breadth = 1 - options.max_breadth = quest_breadth - options.create_variables = True - options.rng = rngs['rng_quest'] - options.restricted_types = {"r", "d"} - chain = sample_quest(world.state, options) + # Sample a quest. + chaining_options = options.chaining.copy() + chaining_options.rules_per_depth = [get_rules().get_matching("^(?!go.*).*")] + chaining_options.backward = True + chaining_options.create_variables = True + chaining_options.rng = rngs['quest'] + chaining_options.restricted_types = {"r", "d"} + chain = sample_quest(world.state, chaining_options) subquests = [] for i in range(1, len(chain.nodes)): @@ -209,68 +188,59 @@ def get_rules(self, depth): world.state = chain.initial_state # Add distractors objects (i.e. not related to the quest) - world.populate(nb_objects, rng=rngs['rng_objects']) + world.populate(options.nb_objects, rng=rngs['objects']) - grammar = make_grammar(grammar_flags, rng=rngs['rng_grammar']) + grammar = make_grammar(options.grammar, rng=rngs['grammar']) game = make_game_with(world, subquests, grammar) game.change_grammar(grammar) + game.metadata["uuid"] = options.uuid return game -def compile_game(game: Game, game_name: Optional[str] = None, - metadata: Mapping = {}, - game_logger: Optional[GameLogger] = None, - games_folder: str = "./gen_games", - force_recompile: bool = False, - file_type: str = ".ulx" - ) -> str: +def compile_game(game: Game, path: str, force_recompile: bool = False): """ Compile a game. Arguments: game: Game object to compile. - game_name: Name of the compiled file (without extension). - If `None`, a unique name will be infered from the game object. - metadata: (Deprecated) contains information about how the game - object was generated. - game_logger: Object used to log stats about generated games. - games_folder: Path to the folder where the compiled game will - be saved. - force_recompile: If `True`, recompile game even if it already - exists. - file_type: Either .z8 (Z-Machine) or .ulx (Glulx). + path: Path of the compiled game (.ulx or .z8). Also, the source (.ni) + and metadata (.json) files will be saved along with it. + force_recompile: If `True`, recompile game even if it already exists. Returns: The path to compiled game. """ - game_name = game_name or game.metadata["uuid"] - source = generate_inform7_source(game) - maybe_mkdir(games_folder) + + folder, filename = os.path.split(path) + if not filename: + filename = game.metadata.get("uuid", str(uuid.uuid4())) + + filename, ext = os.path.splitext(filename) + if not ext: + ext = ".ulx" # Add default extension, if needed. if str2bool(os.environ.get("TEXTWORLD_FORCE_ZFILE", False)): - file_type = ".z8" + ext = ".z8" - game_json = pjoin(games_folder, game_name + ".json") - meta_json = pjoin(games_folder, game_name + ".meta") - game_file = pjoin(games_folder, game_name + file_type) + source = generate_inform7_source(game) + + maybe_mkdir(folder) + game_json = pjoin(folder, filename + ".json") + game_file = pjoin(folder, filename + ext) already_compiled = False # Check if game is already compiled. if not force_recompile and os.path.isfile(game_file) and os.path.isfile(game_json): already_compiled = game == Game.load(game_json) msg = ("It's highly unprobable that two games with the same id have different structures." " That would mean the generator has been modified." - " Please clean already generated games found in '{}'.".format(games_folder)) + " Please clean already generated games found in '{}'.".format(folder)) assert already_compiled, msg if not already_compiled or force_recompile: - json.dump(metadata, open(meta_json, 'w')) game.save(game_json) compile_inform7_game(source, game_file) - if game_logger is not None: - game_logger.collect(game) - return game_file diff --git a/textworld/generator/chaining.py b/textworld/generator/chaining.py index 6b5f8ddb..b5700402 100644 --- a/textworld/generator/chaining.py +++ b/textworld/generator/chaining.py @@ -2,6 +2,7 @@ # Licensed under the MIT license. +import copy from collections import Counter from functools import total_ordering from numpy.random import RandomState @@ -163,6 +164,9 @@ def check_new_variable(self, state: State, type: str, count: int) -> bool: return type not in self.restricted_types + def copy(self) -> "ChainingOptions": + return copy.copy(self) + @total_ordering class _PartialAction: diff --git a/textworld/generator/game.py b/textworld/generator/game.py index 32d1e418..3231f312 100644 --- a/textworld/generator/game.py +++ b/textworld/generator/game.py @@ -2,19 +2,26 @@ # Licensed under the MIT license. +import copy import json -from typing import List, Dict, Optional, Mapping, Any, Iterable +from typing import List, Dict, Optional, Mapping, Any, Iterable, Union from collections import OrderedDict +from numpy.random import RandomState + +from textworld import g_rng +from textworld.utils import encode_seeds from textworld.generator import data -from textworld.generator.text_grammar import Grammar +from textworld.generator.text_grammar import Grammar, GrammarOptions from textworld.generator.world import World from textworld.logic import Action, Proposition, Rule, State from textworld.generator.vtypes import VariableTypeTree from textworld.generator.grammar import get_reverse_action from textworld.generator.graph_networks import DIRECTIONS +from textworld.generator.chaining import ChainingOptions + from textworld.generator.dependency_tree import DependencyTree from textworld.generator.dependency_tree import DependencyTreeElement @@ -372,7 +379,7 @@ def serialize(self) -> Mapping: data["world"] = self.world.serialize() data["state"] = self.state.serialize() if self.grammar is not None: - data["grammar"] = self.grammar.flags.serialize() + data["grammar"] = self.grammar.options.serialize() data["quests"] = [quest.serialize() for quest in self.quests] data["infos"] = [(k, v.serialize()) for k, v in self._infos.items()] data["rules"] = [(k, v.serialize()) for k, v in self._rules.items()] @@ -772,3 +779,125 @@ def update(self, action: Action) -> None: # Update all quest progressions given the last action and new state. for quest_progression in self.quest_progressions: quest_progression.update(action, self.state) + + +class GameOptions: + """ + Options for customizing the game generation. + + Attributes: + nb_rooms: + Number of rooms in the game. + nb_objects: + Number of objects in the game. + quest_length: + Minimum number of actions the quest requires to be completed. + quest_breadth: + Control how nonlinear a quest can be (1: linear). + games_dir: + Path to the directory where the game will be saved. + force_recompile: + If `True`, recompile game even if it already exists. + file_type: + Type of the generated game file. Either .z8 (Z-Machine) or .ulx (Glulx). + seeds: + Seeds for the different generation processes. + + * If `None`, seeds will be sampled from + :py:data:`textworld.g_rng `. + * If `int`, it acts as a seed for a random generator that will be + used to sample the other seeds. + * If dict, the following keys can be set: + + * `'map'`: control the map generation; + * `'objects'`: control the type of objects and their + location; + * `'quest'`: control the quest generation; + * `'grammar'`: control the text generation. + + For any key missing, a random number gets assigned (sampled + from :py:data:`textworld.g_rng `). + chaining: + For customizing the quest generation (see + :py:class:`textworld.generator.ChainingOptions ` + for the list of available options). + grammar: + For customizing the text generation (see + :py:class:`textworld.generator.GrammarOptions ` + for the list of available options). + """ + + def __init__(self): + self.chaining = ChainingOptions() + self.grammar = GrammarOptions() + self._seeds = None + + self.nb_rooms = 1 + self.nb_objects = 1 + self.quest_length = 1 + self.quest_breadth = 1 + self.force_recompile = False + + @property + def quest_length(self) -> int: + return self.chaining.max_depth + + @quest_length.setter + def quest_length(self, value: int) -> None: + self.chaining.min_depth = 1 + self.chaining.max_depth = value + + @property + def quest_breadth(self) -> int: + return self.chaining.max_breadth + + @quest_breadth.setter + def quest_breadth(self, value: int) -> None: + self.chaining.min_breadth = 1 + self.chaining.max_breadth = value + + @property + def seeds(self): + return self._seeds + + @seeds.setter + def seeds(self, value: Union[int, Mapping[str, int]]) -> None: + keys = ['map', 'objects', 'quest', 'grammar'] + + def _key_missing(seeds): + return not set(seeds.keys()).issuperset(keys) + + seeds = value + if type(value) is int: + rng = RandomState(value) + seeds = {} + elif _key_missing(value): + rng = g_rng.next() + + # Check if we need to generate missing seeds. + self._seeds = {} + for key in keys: + if key in seeds: + self._seeds[key] = seeds[key] + else: + self._seeds[key] = rng.randint(65635) + + @property + def rngs(self) -> Dict[str, RandomState]: + rngs = {} + for key, seed in self._seeds.items(): + rngs[key] = RandomState(seed) + + return rngs + + def copy(self) -> "GameOptions": + return copy.copy(self) + + @property + def uuid(self) -> str: + # TODO: generate uuid from chaining options? + uuid = "tw-game-{specs}-{grammar}-{seeds}" + uuid = uuid.format(specs=encode_seeds((self.nb_rooms, self.nb_objects, self.quest_length, self.quest_breadth)), + grammar=self.grammar.uuid, + seeds=encode_seeds([self.seeds[k] for k in sorted(self._seeds)])) + return uuid diff --git a/textworld/generator/inform7/tests/test_world2inform7.py b/textworld/generator/inform7/tests/test_world2inform7.py index 81d34941..cbeb0c02 100644 --- a/textworld/generator/inform7/tests/test_world2inform7.py +++ b/textworld/generator/inform7/tests/test_world2inform7.py @@ -3,6 +3,7 @@ import itertools +from os.path import join as pjoin import textworld from textworld import g_rng @@ -33,7 +34,7 @@ def test_quest_winning_condition_go(): game = M.build() game_name = "test_quest_winning_condition_go" with make_temp_directory(prefix=game_name) as tmpdir: - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) env = textworld.start(game_file) env.reset() @@ -73,7 +74,7 @@ def test_quest_winning_condition(): game_name = "test_quest_winning_condition_" + rule.name.replace("/", "_") with make_temp_directory(prefix=game_name) as tmpdir: - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) env = textworld.start(game_file) env.reset() @@ -117,7 +118,7 @@ def test_quest_losing_condition(): game_name = "test_quest_losing_condition" with make_temp_directory(prefix=game_name) as tmpdir: - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) env = textworld.start(game_file) env.reset() @@ -145,7 +146,7 @@ def test_names_disambiguation(): game = M.build() game_name = "test_names_disambiguation" with make_temp_directory(prefix=game_name) as tmpdir: - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) env = textworld.start(game_file) env.reset() game_state, _, done = env.step("take tasty apple") @@ -174,7 +175,7 @@ def test_names_disambiguation(): path = M.connect(roomA.east, roomB.west) gateway = M.new_door(path, name="gateway") - + path = M.connect(roomA.west, roomC.east) rectangular_gateway = M.new_door(path, name="rectangular gateway") @@ -190,7 +191,7 @@ def test_names_disambiguation(): game = M.build() game_name = "test_names_disambiguation" with make_temp_directory(prefix=game_name) as tmpdir: - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) env = textworld.start(game_file) env.reset() game_state, _, done = env.step("take keycard") @@ -219,19 +220,19 @@ def test_names_disambiguation(): garage = M.new_room("garage") M.set_player(garage) - key = M.new(type="k", name="key") + key = M.new(type="k", name="key") typeG_safe = M.new(type="c", name="type G safe") safe = M.new(type="c", name="safe") safe.add(key) garage.add(safe, typeG_safe) - + M.add_fact("open", safe) game = M.build() game_name = "test_names_disambiguation" with make_temp_directory(prefix=game_name) as tmpdir: - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) env = textworld.start(game_file) game_state = env.reset() game_state, _, done = env.step("take key from safe") @@ -242,19 +243,19 @@ def test_names_disambiguation(): garage = M.new_room("garage") M.set_player(garage) - key = M.new(type="k", name="key") + key = M.new(type="k", name="key") safe = M.new(type="c", name="safe") typeG_safe = M.new(type="c", name="type G safe") safe.add(key) garage.add(safe, typeG_safe) - + M.add_fact("open", safe) game = M.build() game_name = "test_names_disambiguation" with make_temp_directory(prefix=game_name) as tmpdir: - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) env = textworld.start(game_file) game_state = env.reset() game_state, _, done = env.step("take key from safe") @@ -271,7 +272,7 @@ def test_take_all_and_variants(): game = M.build() game_name = "test_take_all_and_variants" with make_temp_directory(prefix=game_name) as tmpdir: - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) env = textworld.start(game_file) env.reset() @@ -289,7 +290,7 @@ def test_take_all_and_variants(): game = M.build() game_name = "test_take_all_and_variants2" with make_temp_directory(prefix=game_name) as tmpdir: - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) env = textworld.start(game_file) env.reset() diff --git a/textworld/generator/maker.py b/textworld/generator/maker.py index 19dbec2f..200160b1 100644 --- a/textworld/generator/maker.py +++ b/textworld/generator/maker.py @@ -509,7 +509,7 @@ def test(self) -> None: This launches a `textworld.play` session. """ with make_temp_directory() as tmpdir: - game_file = self.compile(pjoin(tmpdir, "test_game")) + game_file = self.compile(pjoin(tmpdir, "test_game.ulx")) textworld.play(game_file) def record_quest(self, ask_for_state: bool = False) -> Quest: @@ -527,7 +527,7 @@ def record_quest(self, ask_for_state: bool = False) -> Quest: The resulting quest. """ with make_temp_directory() as tmpdir: - game_file = self.compile(pjoin(tmpdir, "record_quest")) + game_file = self.compile(pjoin(tmpdir, "record_quest.ulx")) recorder = Recorder() textworld.play(game_file, wrapper=recorder) @@ -565,7 +565,7 @@ def set_quest_from_commands(self, commands: List[str], ask_for_state: bool = Fal """ with make_temp_directory() as tmpdir: try: - game_file = self.compile(pjoin(tmpdir, "record_quest")) + game_file = self.compile(pjoin(tmpdir, "record_quest.ulx")) recorder = Recorder() agent = textworld.agents.WalkthroughAgent(commands) textworld.play(game_file, agent=agent, wrapper=recorder, silent=True) @@ -612,7 +612,7 @@ def new_quest_using_commands(self, commands: List[str]) -> Quest: """ with make_temp_directory() as tmpdir: try: - game_file = self.compile(pjoin(tmpdir, "record_quest")) + game_file = self.compile(pjoin(tmpdir, "record_quest.ulx")) recorder = Recorder() agent = textworld.agents.WalkthroughAgent(commands) textworld.play(game_file, agent=agent, wrapper=recorder, silent=True) @@ -699,14 +699,14 @@ def build(self, validate: bool = True) -> Game: self._game = game # Keep track of previous build. return self._game - def compile(self, name: str) -> str: + def compile(self, path: str) -> str: """ Compile this game. Parameters ---------- - name : - Name of the generated game file (without extension). + path : + Path where to save the generated game. Returns ------- @@ -714,9 +714,7 @@ def compile(self, name: str) -> str: Path to the game file. """ self._working_game = self.build() - games_folder = os.path.dirname(os.path.abspath(name)) - game_name = os.path.basename(os.path.splitext(name)[0]) - game_file = textworld.generator.compile_game(self._working_game, game_name, force_recompile=True, games_folder=games_folder) + game_file = textworld.generator.compile_game(self._working_game, path, force_recompile=True) return game_file def __contains__(self, entity) -> bool: diff --git a/textworld/generator/tests/test_game.py b/textworld/generator/tests/test_game.py index 334ce6de..cda56359 100644 --- a/textworld/generator/tests/test_game.py +++ b/textworld/generator/tests/test_game.py @@ -14,7 +14,6 @@ from textworld.generator import data from textworld.generator import World -from textworld.generator import compile_game, make_game from textworld.generator import make_small_map, make_grammar, make_game_with from textworld.generator.chaining import ChainingOptions, sample_quest @@ -43,30 +42,35 @@ def _apply_command(command: str, game_progression: GameProgression) -> None: def test_game_comparison(): - rngs = {} - rngs['rng_map'] = np.random.RandomState(1) - rngs['rng_objects'] = np.random.RandomState(2) - rngs['rng_quest'] = np.random.RandomState(3) - rngs['rng_grammar'] = np.random.RandomState(4) - game1 = make_game(world_size=5, nb_objects=5, quest_length=2, quest_breadth=2, grammar_flags={}, rngs=rngs) - - rngs['rng_map'] = np.random.RandomState(1) - rngs['rng_objects'] = np.random.RandomState(2) - rngs['rng_quest'] = np.random.RandomState(3) - rngs['rng_grammar'] = np.random.RandomState(4) - game2 = make_game(world_size=5, nb_objects=5, quest_length=2, quest_breadth=2, grammar_flags={}, rngs=rngs) + options = textworld.GameOptions() + options.nb_rooms = 5 + options.nb_objects = 5 + options.quest_length = 2 + options.quest_breadth = 2 + options.seeds = {"map": 1, "objects": 2, "quest": 3, "grammar": 4} + game1 = textworld.generator.make_game(options) + game2 = textworld.generator.make_game(options) assert game1 == game2 # Test __eq__ assert game1 in {game2} # Test __hash__ - game3 = make_game(world_size=5, nb_objects=5, quest_length=2, quest_breadth=2, grammar_flags={}, rngs=rngs) + options = options.copy() + options.seeds = {"map": 4, "objects": 3, "quest": 2, "grammar": 1} + game3 = textworld.generator.make_game(options) assert game1 != game3 def test_variable_infos(verbose=False): - g_rng.set_seed(1234) - grammar_flags = {"theme": "house", "include_adj": True} - game = textworld.generator.make_game(world_size=5, nb_objects=10, quest_length=3, quest_breadth=2, grammar_flags=grammar_flags) + options = textworld.GameOptions() + options.nb_rooms = 5 + options.nb_objects = 10 + options.quest_length = 3 + options.quest_breadth = 2 + options.seeds = 1234 + options.grammar.theme = "house" + options.grammar.include_adj = True + + game = textworld.generator.make_game(options) for var_id, var_infos in game.infos.items(): if var_id not in ["P", "I"]: diff --git a/textworld/generator/tests/test_logger.py b/textworld/generator/tests/test_logger.py index 9d5eed4f..ca072bd4 100644 --- a/textworld/generator/tests/test_logger.py +++ b/textworld/generator/tests/test_logger.py @@ -16,9 +16,14 @@ def test_logger(): game_logger = GameLogger() for _ in range(10): - seed = rng.randint(65635) - g_rng.set_seed(seed) - game = textworld.generator.make_game(world_size=5, nb_objects=10, quest_length=3, quest_breadth=3) + options = textworld.GameOptions() + options.nb_rooms = 5 + options.nb_objects = 10 + options.quest_length = 3 + options.quest_breadth = 3 + options.seeds = rng.randint(65635) + + game = textworld.generator.make_game(options) game_logger.collect(game) with make_temp_directory(prefix="textworld_tests") as tests_folder: diff --git a/textworld/generator/tests/test_maker.py b/textworld/generator/tests/test_maker.py index 252b2803..f5fc34b3 100644 --- a/textworld/generator/tests/test_maker.py +++ b/textworld/generator/tests/test_maker.py @@ -27,11 +27,11 @@ def compile_game(game, folder): "instruction_extension": [] } rng_grammar = np.random.RandomState(1234) - grammar = textworld.generator.make_grammar(flags=grammar_flags, rng=rng_grammar) + grammar = textworld.generator.make_grammar(grammar_flags, rng=rng_grammar) game.change_grammar(grammar) game_name = "test_game" - game_file = textworld.generator.compile_game(game, game_name, games_folder=folder) + game_file = textworld.generator.compile_game(game, path=folder) return game_file diff --git a/textworld/generator/tests/test_making_a_game.py b/textworld/generator/tests/test_making_a_game.py index 07f0da83..c002a0a2 100644 --- a/textworld/generator/tests/test_making_a_game.py +++ b/textworld/generator/tests/test_making_a_game.py @@ -34,14 +34,14 @@ def test_making_a_game_without_a_quest(play_the_game=False): "refer_by_name_only": True, "instruction_extension": [], } - grammar = textworld.generator.make_grammar(flags=grammar_flags, rng=rng_grammar) + grammar = textworld.generator.make_grammar(grammar_flags, rng=rng_grammar) # Generate the world representation. game = textworld.generator.make_game_with(world, quests, grammar) with make_temp_directory(prefix="test_render_wrapper") as tmpdir: game_name = "test_making_a_game_without_a_quest" - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) if play_the_game: textworld.play(game_file) @@ -71,14 +71,14 @@ def test_making_a_game(play_the_game=False): "refer_by_name_only": True, "instruction_extension": [], } - grammar = textworld.generator.make_grammar(flags=grammar_flags, rng=rng_grammar) + grammar = textworld.generator.make_grammar(grammar_flags, rng=rng_grammar) # Generate the world representation. game = textworld.generator.make_game_with(world, [quest], grammar) with make_temp_directory(prefix="test_render_wrapper") as tmpdir: game_name = "test_making_a_game" - game_file = compile_game(game, game_name, games_folder=tmpdir) + game_file = compile_game(game, path=tmpdir) if play_the_game: textworld.play(game_file) diff --git a/textworld/generator/tests/test_text_generation.py b/textworld/generator/tests/test_text_generation.py index ef5f7fee..e3deca6a 100644 --- a/textworld/generator/tests/test_text_generation.py +++ b/textworld/generator/tests/test_text_generation.py @@ -38,7 +38,7 @@ def test_used_names_is_updated(verbose=False): world.populate_room(10, world.player_room) # Add objects to the starting room. # Generate the world representation. - grammar = textworld.generator.make_grammar(flags={}, rng=np.random.RandomState(42)) + grammar = textworld.generator.make_grammar({}, rng=np.random.RandomState(42)) for k, v in grammar.grammar.items(): grammar.grammar[k] = v[:2] # Force reusing variables. @@ -71,10 +71,10 @@ def test_blend_instructions(verbose=False): game = M.build() - grammar1 = textworld.generator.make_grammar(flags={"blend_instructions": False}, + grammar1 = textworld.generator.make_grammar({"blend_instructions": False}, rng=np.random.RandomState(42)) - grammar2 = textworld.generator.make_grammar(flags={"blend_instructions": True}, + grammar2 = textworld.generator.make_grammar({"blend_instructions": True}, rng=np.random.RandomState(42)) quest.desc = None diff --git a/textworld/generator/tests/test_text_grammar.py b/textworld/generator/tests/test_text_grammar.py index d78f2207..efddf8dd 100644 --- a/textworld/generator/tests/test_text_grammar.py +++ b/textworld/generator/tests/test_text_grammar.py @@ -5,7 +5,7 @@ import unittest from textworld.generator.text_grammar import Grammar -from textworld.generator.text_grammar import GrammarFlags +from textworld.generator.text_grammar import GrammarOptions class ContainsEveryObjectContainer: @@ -13,12 +13,12 @@ def __contains__(self, item): return True -class TestGrammarFlags(unittest.TestCase): +class TestGrammarOptions(unittest.TestCase): def test_serialization(self): - flags = GrammarFlags() - data = flags.serialize() - flags2 = GrammarFlags.deserialize(data) - assert flags == flags2 + options = GrammarOptions() + data = options.serialize() + options2 = GrammarOptions.deserialize(data) + assert options == options2 class GrammarTest(unittest.TestCase): def test_grammar_eq(self): @@ -28,7 +28,7 @@ def test_grammar_eq(self): def test_grammar_eq2(self): grammar = Grammar() - grammar2 = Grammar(flags={'theme': 'something'}) + grammar2 = Grammar(options={'theme': 'something'}) self.assertNotEqual(grammar, grammar2, "Testing two different grammar files are not equal") def test_grammar_get_random_expansion_fail(self): @@ -66,7 +66,7 @@ def generate_name_fail(self): def generate_name_force_numbered(self): suffix = '_1' - grammar = Grammar(flags={'allowed_variables_numbering': True}) + grammar = Grammar(options={'allowed_variables_numbering': True}) name, adj, noun = grammar.generate_name('object', 'vault', False, exclude=ContainsEveryObjectContainer()) self.assertTrue(name.endswith(suffix), 'Checking name ends with suffix') self.assertTrue(adj.endswith(suffix), 'Checking adj ends with suffix') diff --git a/textworld/generator/text_generation.py b/textworld/generator/text_generation.py index 2ba3e81b..b6486b2d 100644 --- a/textworld/generator/text_generation.py +++ b/textworld/generator/text_generation.py @@ -32,8 +32,8 @@ def assign_new_matching_names(obj1_infos, obj2_infos, grammar, exclude=[]): result = grammar.expand(tag) first, second = result.split("<->") # Matching arguments are separated by '<->'. - name1, adj1, noun1 = grammar.split_name_adj_noun(first.strip(), grammar.flags.include_adj) - name2, adj2, noun2 = grammar.split_name_adj_noun(second.strip(), grammar.flags.include_adj) + name1, adj1, noun1 = grammar.split_name_adj_noun(first.strip(), grammar.options.include_adj) + name2, adj2, noun2 = grammar.split_name_adj_noun(second.strip(), grammar.options.include_adj) if name1 not in exclude and name2 not in exclude and name1 != name2: found_matching_names = True break @@ -139,12 +139,12 @@ def generate_text_from_grammar(game, grammar: Grammar): if quest.desc is None: # Skip quests which already have a description. quest.desc = assign_description_to_quest(quest, game, grammar) - if grammar.flags.only_last_action and len(game.quests) > 1: + if grammar.options.only_last_action and len(game.quests) > 1: main_quest = Quest(actions=[quest.actions[-1] for quest in game.quests]) - only_last_action_bkp = grammar.flags.only_last_action - grammar.flags.only_last_action = False + only_last_action_bkp = grammar.options.only_last_action + grammar.options.only_last_action = False game.objective = assign_description_to_quest(main_quest, game, grammar) - grammar.flags.only_last_action = only_last_action_bkp + grammar.options.only_last_action = only_last_action_bkp return game @@ -179,7 +179,7 @@ def assign_description_to_room(room, game, grammar): obj_infos = game.infos[obj.id] adj, noun = obj_infos.adj, obj_infos.noun - if grammar.flags.blend_descriptions: + if grammar.options.blend_descriptions: found = False for type in ["noun", "adj"]: group_filt = [] @@ -239,7 +239,7 @@ def assign_description_to_room(room, game, grammar): exits_desc = [] # Describing exits with door. - if grammar.flags.blend_descriptions and len(exits_with_closed_door) > 1: + if grammar.options.blend_descriptions and len(exits_with_closed_door) > 1: dirs, door_objs = zip(*exits_with_closed_door) e_desc = grammar.expand("#room_desc_doors_closed#") e_desc = replace_num(e_desc, len(door_objs)) @@ -254,7 +254,7 @@ def assign_description_to_room(room, game, grammar): d_desc = d_desc.replace("(dir)", dir_) exits_desc.append(d_desc) - if grammar.flags.blend_descriptions and len(exits_with_open_door) > 1: + if grammar.options.blend_descriptions and len(exits_with_open_door) > 1: dirs, door_objs = zip(*exits_with_open_door) e_desc = grammar.expand("#room_desc_doors_open#") e_desc = replace_num(e_desc, len(door_objs)) @@ -270,7 +270,7 @@ def assign_description_to_room(room, game, grammar): exits_desc.append(d_desc) # Describing exits without door. - if grammar.flags.blend_descriptions and len(exits_without_door) > 1: + if grammar.options.blend_descriptions and len(exits_without_door) > 1: e_desc = grammar.expand("#room_desc_exits#").replace("(dir)", list_to_string(exits_without_door, False)) e_desc = repl_sing_plur(e_desc, len(exits_without_door)) exits_desc.append(e_desc) @@ -351,7 +351,7 @@ def generate_instruction(action, grammar, game_infos, world, counts): obj = world.find_object_by_id(var.name) obj_infos = game_infos[obj.id] - if grammar.flags.ambiguous_instructions: + if grammar.options.ambiguous_instructions: assert False, "not tested" choices = [] @@ -410,13 +410,13 @@ def assign_description_to_quest(quest, game, grammar): quest_desc = "Choose your own adventure!" else: # Generate a description for either the last, or all commands - if grammar.flags.only_last_action: + if grammar.options.only_last_action: actions_desc, _ = generate_instruction(quest.actions[-1], grammar, game.infos, game.world, counts) only_one_action = True else: actions_desc = "" # Decide if we blend instructions together or not - if grammar.flags.blend_instructions: + if grammar.options.blend_instructions: instructions = get_action_chains(quest.actions, grammar, game.infos) else: instructions = quest.actions diff --git a/textworld/generator/text_grammar.py b/textworld/generator/text_grammar.py index 9637e584..33fe0ac4 100644 --- a/textworld/generator/text_grammar.py +++ b/textworld/generator/text_grammar.py @@ -7,7 +7,7 @@ import warnings from os.path import join as pjoin from collections import OrderedDict, defaultdict -from typing import Optional, Mapping, List, Tuple, Container +from typing import Optional, Mapping, List, Tuple, Container, Union from numpy.random import RandomState @@ -35,57 +35,56 @@ def fix_determinant(var): return var -class GrammarFlags: +class GrammarOptions: __slots__ = ['theme', 'names_to_exclude', 'include_adj', 'blend_descriptions', 'ambiguous_instructions', 'only_last_action', 'blend_instructions', 'allowed_variables_numbering', 'unique_expansion'] - def __init__(self, flags=None, **kwargs): - flags = flags or kwargs + def __init__(self, options=None, **kwargs): + if isinstance(options, GrammarOptions): + options = options.serialize() - self.theme = flags.get("theme", "house") - self.names_to_exclude = flags.get("names_to_exclude", []) - self.allowed_variables_numbering = flags.get("allowed_variables_numbering", False) - self.unique_expansion = flags.get("unique_expansion", False) - self.include_adj = flags.get("include_adj", False) - self.only_last_action = flags.get("only_last_action", False) - self.blend_instructions = flags.get("blend_instructions", False) - self.blend_descriptions = flags.get("blend_descriptions", False) - self.ambiguous_instructions = flags.get("ambiguous_instructions", False) + options = options or kwargs + + self.theme = options.get("theme", "house") + self.names_to_exclude = options.get("names_to_exclude", []) + self.allowed_variables_numbering = options.get("allowed_variables_numbering", False) + self.unique_expansion = options.get("unique_expansion", False) + self.include_adj = options.get("include_adj", False) + self.only_last_action = options.get("only_last_action", False) + self.blend_instructions = options.get("blend_instructions", False) + self.blend_descriptions = options.get("blend_descriptions", False) + self.ambiguous_instructions = options.get("ambiguous_instructions", False) def serialize(self) -> Mapping: return {slot: getattr(self, slot) for slot in self.__slots__} @classmethod - def deserialize(cls, data: Mapping) -> "GrammarFlags": + def deserialize(cls, data: Mapping) -> "GrammarOptions": return cls(data) def __eq__(self, other) -> bool: - return (isinstance(other, GrammarFlags) and + return (isinstance(other, GrammarOptions) and all(getattr(self, slot) == getattr(other, slot) for slot in self.__slots__)) - def encode(self) -> str: - """ Generate UUID for this set of grammar flags. - """ + @property + def uuid(self) -> str: + """ Generate UUID for this set of grammar options. """ def _unsigned(n): return n & 0xFFFFFFFFFFFFFFFF # Skip theme and names_to_exclude. values = [int(getattr(self, s)) for s in self.__slots__[2:]] - flag = "".join(map(str, values)) + option = "".join(map(str, values)) from hashids import Hashids hashids = Hashids(salt="TextWorld") if len(self.names_to_exclude) > 0: names_to_exclude_hash = _unsigned(hash(frozenset(self.names_to_exclude))) - return self.theme + "-" + hashids.encode(names_to_exclude_hash) + "-" + hashids.encode(int(flag)) - - return self.theme + "-" + hashids.encode(int(flag)) - + return self.theme + "-" + hashids.encode(names_to_exclude_hash) + "-" + hashids.encode(int(option)) -def encode_flags(flags: Mapping) -> str: - return GrammarFlags(flags).encode() + return self.theme + "-" + hashids.encode(int(option)) class Grammar: @@ -93,29 +92,31 @@ class Grammar: Context-Free Grammar for text generation. """ - def __init__(self, flags: Mapping = {}, rng: Optional[RandomState] = None): + def __init__(self, options: Union[GrammarOptions, Mapping] = {}, rng: Optional[RandomState] = None): """ Create a grammar. - :param flags: - Flags guiding the text generation process. - TODO: describe expected flags. + Arguments: + options: + For customizing text generation process (see + :py:class:`textworld.generator.GrammarOptions ` + for the list of available options). :param rng: Random generator used for sampling tag expansions. """ - self.flags = GrammarFlags(flags) + self.options = GrammarOptions(options) self.grammar = OrderedDict() self.rng = g_rng.next() if rng is None else rng - self.allowed_variables_numbering = self.flags.allowed_variables_numbering - self.unique_expansion = self.flags.unique_expansion + self.allowed_variables_numbering = self.options.allowed_variables_numbering + self.unique_expansion = self.options.unique_expansion self.all_expansions = defaultdict(list) # The current used symbols self.overflow_dict = OrderedDict() - self.used_names = set(self.flags.names_to_exclude) + self.used_names = set(self.options.names_to_exclude) # Load the grammar associated to the provided theme. - self.theme = self.flags.theme + self.theme = self.options.theme grammar_contents = [] # Load the object names file @@ -131,7 +132,7 @@ def __eq__(self, other): return (isinstance(other, Grammar) and self.overflow_dict == other.overflow_dict and self.grammar == other.grammar and - self.flags.encode() == other.flags.encode() and + self.options.uuid == other.options.uuid and self.used_names == other.used_names) def _parse(self, lines: List[str]): @@ -267,7 +268,7 @@ def generate_name(self, obj_type: str, room_type: str = "", include_adj : optional If True, the name can contain a generated adjective. If False, any generated adjective will be discarded. - Default: use value grammar.flags.include_adj + Default: use value grammar.options.include_adj exclude : optional List of names we should avoid generating. @@ -281,7 +282,7 @@ def generate_name(self, obj_type: str, room_type: str = "", The noun part of the name. """ if include_adj is None: - include_adj = self.flags.include_adj + include_adj = self.options.include_adj # Get room-specialized name, if possible. symbol = "#{}_({})#".format(room_type, obj_type) diff --git a/textworld/helpers.py b/textworld/helpers.py index 4c4ee7a7..764f0ffe 100644 --- a/textworld/helpers.py +++ b/textworld/helpers.py @@ -9,7 +9,7 @@ from textworld.utils import g_rng from textworld.core import Environment, GameState, Agent -from textworld.generator import Game, GameMaker +from textworld.generator import Game, GameMaker, GameOptions from textworld.envs import FrotzEnvironment from textworld.envs import GlulxEnvironment @@ -119,25 +119,20 @@ def play(game_file: str, agent: Optional[Agent] = None, max_nb_steps: int = 1000 print(msg) -def make(world_size: int = 1, nb_objects: int = 5, quest_length: int = 2, quest_breadth: int = 1, - grammar_flags: Mapping = {}, seed: int = None, - games_dir: str = "./gen_games/") -> Tuple[str, Game]: +def make(options: GameOptions, path: str) -> Tuple[str, Game]: """ Makes a text-based game. - Args: - world_size: Number of rooms in the world. - nb_objects: Number of objects in the world. - quest_length: Minimum number of actions the quest requires to be completed. - quest_breadth: Control how nonlinear a quest can be (1: linear). - grammar_flags: Grammar options. - seed: Random seed for the game generation process. - games_dir: Path to the directory where the game will be saved. + Arguments: + options: + For customizing the game generation (see + :py:class:`textworld.GameOptions ` + for the list of available options). + path: Path of the compiled game (.ulx or .z8). Also, the source (.ni) + and metadata (.json) files will be saved along with it. Returns: A tuple containing the path to the game file, and its corresponding Game's object. """ - g_rng.set_seed(seed) - game_name = "game_{}".format(seed) - game = make_game(world_size, nb_objects, quest_length, quest_breadth, grammar_flags) - game_file = compile_game(game, game_name, games_folder=games_dir, force_recompile=True) + game = make_game(options) + game_file = compile_game(game, path, force_recompile=True) return game_file, game diff --git a/textworld/render/serve.py b/textworld/render/serve.py index f925b810..4015091c 100644 --- a/textworld/render/serve.py +++ b/textworld/render/serve.py @@ -119,7 +119,8 @@ def __init__(self, game_state: GlulxGameState, open_automatically: bool): self._process = None state_dict = load_state_from_game_state(game_state) self._history = '

{}

'.format(game_state.objective.strip().replace("\n", "
")) - self._history += ''.format(game_state.description.strip().replace("\n", "
")) + initial_description = game_state.feedback.replace(game_state.objective, "") + self._history += ''.format(initial_description.strip().replace("\n", "
")) state_dict["history"] = self._history state_dict["command"] = "" self.parent_conn, self.child_conn = Pipe()