diff --git a/scripts/tw-make b/scripts/tw-make index 938886d4..736751b2 100755 --- a/scripts/tw-make +++ b/scripts/tw-make @@ -46,6 +46,8 @@ def parse_args(): help="Nb. of objects in the world.") custom_parser.add_argument("--quest-length", type=int, default=5, metavar="LENGTH", help="Minimum nb. of actions the quest requires to be completed.") + custom_parser.add_argument("--quest-breadth", type=int, default=3, metavar="BREADTH", + help="Control how non-linear a quest can be.") challenge_parser = subparsers.add_parser("challenge", parents=[general_parser], help='Generate a game for one of the challenges.') @@ -72,7 +74,7 @@ if __name__ == "__main__": } if args.subcommand == "custom": - game_file, game = textworld.make(args.world_size, args.nb_objects, args.quest_length, grammar_flags, + 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) elif args.subcommand == "challenge": @@ -87,7 +89,7 @@ if __name__ == "__main__": print("Game generated: {}".format(game_file)) if args.verbose: - print(game.quests[0].desc) + print(game.objective) if args.view: textworld.render.visualize(game, interactive=True) diff --git a/scripts/tw-stats b/scripts/tw-stats index 21145ec9..dbef4559 100755 --- a/scripts/tw-stats +++ b/scripts/tw-stats @@ -38,7 +38,7 @@ if __name__ == "__main__": continue if len(game.quests) > 0: - objectives[game_filename] = game.quests[0].desc + objectives[game_filename] = game.objective names |= set(info.name for info in game.infos.values() if info.name is not None) game_logger.collect(game) diff --git a/scripts_dev/benchmark_framework.py b/scripts_dev/benchmark_framework.py index 5a9d12bd..f82adff8 100644 --- a/scripts_dev/benchmark_framework.py +++ b/scripts_dev/benchmark_framework.py @@ -10,35 +10,14 @@ from textworld.generator import World -def generate_never_ending_game_old(args): - g_rng.set_seed(args.seed) - msg = "--max-steps {} --nb-objects {} --nb-rooms {} --seed {}" - print(msg.format(args.max_steps, args.nb_objects, args.nb_rooms, g_rng.seed)) - print("Generating game...") - - map_ = textworld.generator.make_map(n_rooms=args.nb_rooms) - world = World.from_map(map_) - world.set_player_room() - world.populate(nb_objects=args.nb_objects) - grammar = textworld.generator.make_grammar(flags={"theme": "house"}) - - quests = [] # No quest - game = textworld.generator.make_game_with(world, quests, grammar) - - game_name = "neverending" - game_file = textworld.generator.compile_game(game, game_name, force_recompile=True, - games_folder=args.output) - return game_file - - def generate_never_ending_game(args): g_rng.set_seed(args.seed) - msg = "--max-steps {} --nb-objects {} --nb-rooms {} --quest-length {} --seed {}" - print(msg.format(args.max_steps, args.nb_objects, args.nb_rooms, args.quest_length, g_rng.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, grammar_flags) + game = textworld.generator.make_game(args.nb_rooms, args.nb_objects, args.quest_length, args.quest_breadth, grammar_flags) if args.no_quest: game.quests = [] @@ -52,9 +31,11 @@ def benchmark(game_file, args): print("Using {}".format(env.__class__.__name__)) if args.mode == "random": - agent = textworld.agents.RandomTextAgent() + agent = textworld.agents.NaiveAgent() elif args.mode == "random-cmd": agent = textworld.agents.RandomCommandAgent() + elif args.mode == "walkthrough": + agent = textworld.agents.WalkthroughAgent() agent.reset(env) @@ -96,13 +77,15 @@ def parse_args(): help="Nb. of rooms in the world. Default: %(default)s") parser.add_argument("--nb-objects", type=int, default=50, help="Nb. of objects in the world. Default: %(default)s") - parser.add_argument("--quest-length", type=int, default=10, + parser.add_argument("--quest-length", type=int, default=5, help="Minimum nb. of actions the quest requires to be completed. Default: %(default)s") + parser.add_argument("--quest-breadth", type=int, default=3, + help="Control how non-linear a quest can be. Default: %(default)s") parser.add_argument("--max-steps", type=int, default=1000, help="Stop the game after that many steps. Default: %(default)s") parser.add_argument("--output", default="./gen_games/", help="Output folder to save generated game files.") - parser.add_argument("--mode", default="random-cmd", choices=["random", "random-cmd"]) + parser.add_argument("--mode", default="random-cmd", choices=["random", "random-cmd", "walkthrough"]) parser.add_argument("--no-quest", action="store_true") parser.add_argument("--compute_intermediate_reward", action="store_true") parser.add_argument("--activate_state_tracking", action="store_true") diff --git a/tests/test_make_game.py b/tests/test_make_game.py index 6a6c6263..b8b9be69 100644 --- a/tests/test_make_game.py +++ b/tests/test_make_game.py @@ -11,11 +11,11 @@ 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, {"names_to_exclude": []}, + game_file1, game1 = textworld.make(2, 20, 3, 3, {"names_to_exclude": []}, seed=123, games_dir=tmpdir) 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, {"names_to_exclude": game1_objects_names}, + game_file2, game2 = textworld.make(2, 20, 3, 3, {"names_to_exclude": game1_objects_names}, seed=123, games_dir=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 @@ -24,8 +24,8 @@ def test_making_game_with_names_to_exclude(): 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, grammar_flags, seed=123, games_dir=tmpdir) - game_file2, game2 = textworld.make(2, 20, 3, grammar_flags, seed=123, games_dir=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) 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 2be94426..0fd050c5 100644 --- a/tests/test_play_generated_games.py +++ b/tests/test_play_generated_games.py @@ -16,12 +16,13 @@ def test_play_generated_games(): # Sample game specs. world_size = rng.randint(1, 10) nb_objects = rng.randint(0, 20) - quest_length = rng.randint(1, 10) + 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, grammar_flags, + game_file, game = textworld.make(world_size, nb_objects, quest_length, quest_breadth, grammar_flags, seed=game_seed, games_dir=tmpdir) # Solve the game using WalkthroughAgent. diff --git a/tests/test_textworld.py b/tests/test_textworld.py index a0165e8a..2dd5499f 100644 --- a/tests/test_textworld.py +++ b/tests/test_textworld.py @@ -58,7 +58,7 @@ def test_game_walkthrough_agent(self): agent = textworld.agents.WalkthroughAgent() env = textworld.start(self.game_file) env.activate_state_tracking() - commands = self.game.quests[0].commands + commands = self.game.main_quest.commands agent.reset(env) game_state = env.reset() diff --git a/tests/test_tw_play.py b/tests/test_tw-play.py similarity index 66% rename from tests/test_tw_play.py rename to tests/test_tw-play.py index 6ff2dfea..adc305b1 100644 --- a/tests/test_tw_play.py +++ b/tests/test_tw-play.py @@ -7,9 +7,9 @@ from textworld.utils import make_temp_directory -def test_making_a_custom_game(): - with make_temp_directory(prefix="test_tw-play") as tmpdir: - game_file, _ = textworld.make(5, 10, 5, {}, seed=1234, games_dir=tmpdir) +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) command = ["tw-play", "--max-steps", "100", "--mode", "random", game_file] assert check_call(command) == 0 @@ -18,4 +18,4 @@ def test_making_a_custom_game(): assert check_call(command) == 0 command = ["tw-play", "--max-steps", "100", "--mode", "walkthrough", game_file] - assert check_call(command) == 0 + assert check_call(command) == 0 \ No newline at end of file diff --git a/textworld/agents/walkthrough.py b/textworld/agents/walkthrough.py index 7b046b86..9b214d8f 100644 --- a/textworld/agents/walkthrough.py +++ b/textworld/agents/walkthrough.py @@ -26,7 +26,7 @@ def reset(self, env): raise NameError(msg) # Load command from the generated game. - self._commands = iter(env.game.quests[0].commands) + self._commands = iter(env.game.main_quest.commands) def act(self, game_state, reward, done): try: diff --git a/textworld/envs/glulx/git_glulx_ml.py b/textworld/envs/glulx/git_glulx_ml.py index ac0572bc..ea959793 100644 --- a/textworld/envs/glulx/git_glulx_ml.py +++ b/textworld/envs/glulx/git_glulx_ml.py @@ -89,7 +89,7 @@ def _detect_i7_events_debug_tags(text: str) -> Tuple[List[str], str]: """ matches = [] open_tags = [] - for match in re.findall("\[[^]]+\]\n?", text): + for match in re.findall(r"\[[^]]+\]\n?", text): text = text.replace(match, "") # Remove i7 debug tags. tag_name = match.strip()[1:-1] # Strip starting '[' and trailing ']'. @@ -127,12 +127,12 @@ def __init__(self, *args, **kwargs): :param kwargs: The kwargs """ super().__init__(*args, **kwargs) - self._has_won = False - self._has_lost = False + self.has_timeout = False self._state_tracking = False self._compute_intermediate_reward = False + self._max_score = 0 - def init(self, output: str, game=None, + def init(self, output: str, game: Game, state_tracking: bool = False, compute_intermediate_reward: bool = False): """ Initialize the game state and set tracking parameters. @@ -149,10 +149,9 @@ def init(self, output: str, game=None, self._game_progression = GameProgression(game, track_quests=compute_intermediate_reward) self._state_tracking = state_tracking self._compute_intermediate_reward = compute_intermediate_reward and len(game.quests) > 0 - - self._objective = "" - if len(game.quests) > 0: - self._objective = game.quests[0].desc + self._objective = game.objective + self._score = 0 + self._max_score = self._game_progression.max_score def view(self) -> "GlulxGameState": """ @@ -177,6 +176,7 @@ def view(self) -> "GlulxGameState": game_state._nb_moves = self.nb_moves game_state._has_won = self.has_won game_state._has_lost = self.has_lost + game_state.has_timeout = self.has_timeout if self._state_tracking: game_state._admissible_commands = self.admissible_commands @@ -199,6 +199,7 @@ def update(self, command: str, output: str) -> "GlulxGameState": game_state = super().update(command, output) game_state.previous_state = self.view() game_state._objective = self.objective + game_state._max_score = self.max_score game_state._game_progression = self._game_progression game_state._state_tracking = self._state_tracking game_state._compute_intermediate_reward = self._compute_intermediate_reward @@ -215,12 +216,6 @@ def update(self, command: str, output: str) -> "GlulxGameState": # An action that affects the state of the game. game_state._game_progression.update(game_state._action) - if game_state._compute_intermediate_reward: - if game_state._game_progression.winning_policy is None: - game_state._has_lost = True - elif len(game_state._game_progression.winning_policy) == 0: - game_state._has_won = True - return game_state @property @@ -317,24 +312,54 @@ def intermediate_reward(self): @property def score(self): - if self.has_won: - return 1 - elif self.has_lost: - return -1 - - return 0 + if not hasattr(self, "_score"): + if self._state_tracking: + self._score = self._game_progression.score + else: + + # Check if there was any Inform7 events. + if self._feedback == self._raw: + self._score = self.previous_state.score + else: + output = self._raw + if not self.game_ended: + output = self._env._send("score") + + match = re.search("scored (?P[0-9]+) out of a possible (?P[0-9]+),", output) + self._score = 0 + if match: + self._score = int(match.groupdict()["score"]) + + return self._score @property def max_score(self): - return 1 + return self._max_score @property def has_won(self): - return self._has_won or '*** The End ***' in self.feedback + if not hasattr(self, "_has_won"): + if self._compute_intermediate_reward: + self._has_won = self._game_progression.completed + else: + self._has_won = '*** The End ***' in self.feedback + + return self._has_won @property def has_lost(self): - return self._has_lost or '*** You lost! ***' in self.feedback + if not hasattr(self, "_has_lost"): + if self._compute_intermediate_reward: + self._has_lost = self._game_progression.failed + else: + self._has_lost = '*** You lost! ***' in self.feedback + + return self._has_lost + + @property + def game_ended(self) -> bool: + """ Whether the game is finished or not. """ + return self.has_won | self.has_lost | self.has_timeout @property def game_infos(self) -> Mapping: @@ -439,8 +464,8 @@ def step(self, command: str) -> Tuple[GlulxGameState, float, bool]: raise GameNotRunningError() self.game_state = self.game_state.update(command, output) - done = self.game_state.game_ended or not self.game_running - return self.game_state, self.game_state.score, done + self.game_state.has_timeout = not self.game_running + return self.game_state, self.game_state.score, self.game_state.game_ended def _send(self, command: str) -> Union[str, None]: if not self.game_running: diff --git a/textworld/envs/glulx/tests/test_git_glulx_ml.py b/textworld/envs/glulx/tests/test_git_glulx_ml.py index 093f4331..5e1d706e 100644 --- a/textworld/envs/glulx/tests/test_git_glulx_ml.py +++ b/textworld/envs/glulx/tests/test_git_glulx_ml.py @@ -47,7 +47,12 @@ def build_test_game(): chest.add_property("open") R2.add(chest) - M.set_quest_from_commands(commands) + quest1 = M.new_quest_using_commands(commands) + quest1.reward = 2 + quest2 = M.new_quest_using_commands(commands + ["close chest"]) + quest2.set_winning_conditions([M.new_fact("in", carrot, chest), + M.new_fact("closed", chest)]) + M._quests = [quest1, quest2] game = M.build() return game @@ -128,7 +133,10 @@ def test_inventory(self): game_state, _, _ = self.env.step("take carrot") game_state, _, _ = self.env.step("go east") game_state, _, _ = self.env.step("insert carrot into chest") - assert game_state.inventory == "" + assert "carrying nothing" in game_state.inventory + + game_state, _, _ = self.env.step("close chest") + assert game_state.inventory == "" # Game has ended def test_objective(self): assert self.game_state.objective.strip() in self.game_state.feedback @@ -145,16 +153,19 @@ def test_description(self): # End the game. game_state, _, _ = self.env.step("insert carrot into chest") + game_state, _, _ = self.env.step("close chest") assert game_state.description == "" def test_score(self): assert self.game_state.score == 0 - assert self.game_state.max_score == 1 + assert self.game_state.max_score == 3 game_state, _, _ = self.env.step("go east") assert game_state.score == 0 game_state, _, _ = self.env.step("insert carrot into chest") - assert game_state.score == 1 - assert game_state.max_score == 1 + assert game_state.score == 2 + assert game_state.max_score == 3 + game_state, _, _ = self.env.step("close chest") + assert game_state.score == 3 def test_game_ended_when_no_quest(self): M = GameMaker() @@ -184,6 +195,8 @@ def test_has_won(self): game_state, _, _ = self.env.step("go east") assert not game_state.has_won game_state, _, done = self.env.step("insert carrot into chest") + assert not game_state.has_won + game_state, _, done = self.env.step("close chest") assert game_state.has_won def test_has_lost(self): @@ -210,31 +223,33 @@ def test_intermediate_reward(self): game_state, _, _ = self.env.step("close wooden door") assert game_state.intermediate_reward == 0 game_state, _, done = self.env.step("insert carrot into chest") + game_state, _, done = self.env.step("close chest") assert done assert game_state.has_won assert game_state.intermediate_reward == 1 def test_policy_commands(self): - assert self.game_state.policy_commands == self.game.quests[0].commands + assert self.game_state.policy_commands == self.game.main_quest.commands game_state, _, _ = self.env.step("drop carrot") - expected = ["take carrot"] + self.game.quests[0].commands + expected = ["take carrot"] + self.game.main_quest.commands assert game_state.policy_commands == expected, game_state.policy_commands game_state, _, _ = self.env.step("take carrot") - expected = self.game.quests[0].commands + expected = self.game.main_quest.commands assert game_state.policy_commands == expected game_state, _, _ = self.env.step("go east") - expected = self.game.quests[0].commands[1:] + expected = self.game.main_quest.commands[1:] assert game_state.policy_commands == expected game_state, _, _ = self.env.step("insert carrot into chest") + game_state, _, _ = self.env.step("close chest") assert game_state.policy_commands == [], game_state.policy_commands # Test parallel subquests. game_state = self.env.reset() - commands = self.game.quests[0].commands + commands = self.game.main_quest.commands assert game_state.policy_commands == commands game_state, _, _ = self.env.step("close wooden door") assert game_state.policy_commands == ["open wooden door"] + commands @@ -248,7 +263,7 @@ def test_policy_commands(self): # Irreversible action. game_state = self.env.reset() - assert game_state.policy_commands == self.game.quests[0].commands + assert game_state.policy_commands == self.game.main_quest.commands game_state, _, done = self.env.step("eat carrot") assert done assert game_state.has_lost @@ -256,7 +271,7 @@ def test_policy_commands(self): def test_admissible_commands(self): game_state = self.env.reset() - for command in self.game.quests[0].commands: + for command in self.game.main_quest.commands: assert command in game_state.admissible_commands game_state, _, done = self.env.step(command) diff --git a/textworld/envs/wrappers/tests/test_viewer.py b/textworld/envs/wrappers/tests/test_viewer.py index ac40fbb0..32a4c4fe 100644 --- a/textworld/envs/wrappers/tests/test_viewer.py +++ b/textworld/envs/wrappers/tests/test_viewer.py @@ -17,7 +17,7 @@ def test_html_viewer(): 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, grammar_flags=grammar_flags) + game = textworld.generator.make_game(world_size=num_nodes, nb_objects=num_items, quest_length=3, quest_breadth=1, grammar_flags=grammar_flags) game_name = "test_html_viewer_wrapper" with make_temp_directory(prefix=game_name) as tmpdir: diff --git a/textworld/generator/__init__.py b/textworld/generator/__init__.py index 7db5d7da..8496cece 100644 --- a/textworld/generator/__init__.py +++ b/textworld/generator/__init__.py @@ -147,7 +147,7 @@ def make_game_with(world, quests=None, grammar=None): return game -def make_game(world_size: int, nb_objects: int, quest_length: int, +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: @@ -158,6 +158,7 @@ def make_game(world_size: int, nb_objects: int, quest_length: int, 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. Returns: @@ -175,14 +176,34 @@ def make_game(world_size: int, nb_objects: int, quest_length: int, world = make_world(world_size, nb_objects=0, rngs=rngs) # Sample a quest according to quest_length. - options = ChainingOptions() + 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) + + subquests = [] + for i in range(1, len(chain.nodes)): + if chain.nodes[i].breadth != chain.nodes[i - 1].breadth: + quest = Quest(chain.actions[:i]) + subquests.append(quest) + quest = Quest(chain.actions) + subquests.append(quest) # Set the initial state required for the quest. world.state = chain.initial_state @@ -191,7 +212,9 @@ def make_game(world_size: int, nb_objects: int, quest_length: int, world.populate(nb_objects, rng=rngs['rng_objects']) grammar = make_grammar(grammar_flags, rng=rngs['rng_grammar']) - game = make_game_with(world, [quest], grammar) + game = make_game_with(world, subquests, grammar) + game.change_grammar(grammar) + return game diff --git a/textworld/generator/data/logic/door.twl b/textworld/generator/data/logic/door.twl index c8a56384..54613ab5 100644 --- a/textworld/generator/data/logic/door.twl +++ b/textworld/generator/data/logic/door.twl @@ -34,7 +34,13 @@ type d : t { link3 :: link(r, d, r') & link(r, d', r') -> fail(); # There cannot be more than four doors in a room. - dr2 :: link(r, d1: d, r1: r) & link(r, d2: d, r2: r) & link(r, d3: d, r3: r) & link(r, d4: d, r4: r) & link(r, d5: d, r5: r) -> fail(); + too_many_doors :: link(r, d1: d, r1: r) & link(r, d2: d, r2: r) & link(r, d3: d, r3: r) & link(r, d4: d, r4: r) & link(r, d5: d, r5: r) -> fail(); + + # There cannot be more than four doors in a room. + dr1 :: free(r, r1: r) & link(r, d2: d, r2: r) & link(r, d3: d, r3: r) & link(r, d4: d, r4: r) & link(r, d5: d, r5: r) -> fail(); + dr2 :: free(r, r1: r) & free(r, r2: r) & link(r, d3: d, r3: r) & link(r, d4: d, r4: r) & link(r, d5: d, r5: r) -> fail(); + dr3 :: free(r, r1: r) & free(r, r2: r) & free(r, r3: r) & link(r, d4: d, r4: r) & link(r, d5: d, r5: r) -> fail(); + dr4 :: free(r, r1: r) & free(r, r2: r) & free(r, r3: r) & free(r, r4: r) & link(r, d5: d, r5: r) -> fail(); free1 :: link(r, d, r') & free(r, r') & closed(d) -> fail(); free2 :: link(r, d, r') & free(r, r') & locked(d) -> fail(); diff --git a/textworld/generator/data/text_grammars/house_instruction.twg b/textworld/generator/data/text_grammars/house_instruction.twg index e88605e8..7888718d 100644 --- a/textworld/generator/data/text_grammars/house_instruction.twg +++ b/textworld/generator/data/text_grammars/house_instruction.twg @@ -112,7 +112,7 @@ action_seperator_go/north: #afterhave# gone north, ;#emptyinstruction1#;#emptyin action_seperator_go/east: #afterhave# gone east, ;#emptyinstruction1#;#emptyinstruction2#;#emptyinstruction3#;#emptyinstruction4#;#emptyinstruction5#;#emptyinstruction6#;#emptyinstruction7#;#emptyinstruction8#;#emptyinstruction9#;#emptyinstruction10# action_seperator_go/west: #afterhave# gone west, ;#emptyinstruction1#;#emptyinstruction2#;#emptyinstruction3#;#emptyinstruction4#;#emptyinstruction5#;#emptyinstruction6#;#emptyinstruction7#;#emptyinstruction8#;#emptyinstruction9#;#emptyinstruction10# action_separator_close: #afterhave# #closed# the #close_open_types#, ; #after# #closing# the #close_open_types#, ;#emptyinstruction1#;#emptyinstruction2#;#emptyinstruction3#;#emptyinstruction4#;#emptyinstruction5#;#emptyinstruction6#;#emptyinstruction7#;#emptyinstruction8#;#emptyinstruction9#;#emptyinstruction10# -action_separator_drop: #afterhave# #dropped# #obj_types#, ; #after# #dropping# #obj_types#, ;#emptyinstruction1#;#emptyinstruction2#;#emptyinstruction3#;#emptyinstruction4#;#emptyinstruction5#;#emptyinstruction6#;#emptyinstruction7#;#emptyinstruction8#;#emptyinstruction9#;#emptyinstruction10# +action_separator_drop: #afterhave# #dropped# the #obj_types#, ; #after# #dropping# the #obj_types#, ;#emptyinstruction1#;#emptyinstruction2#;#emptyinstruction3#;#emptyinstruction4#;#emptyinstruction5#;#emptyinstruction6#;#emptyinstruction7#;#emptyinstruction8#;#emptyinstruction9#;#emptyinstruction10# #Separator Symbols afterhave:After you have;Having;Once you have;If you have havetaken:taken;got;picked up diff --git a/textworld/generator/game.py b/textworld/generator/game.py index 01ff17cb..32d1e418 100644 --- a/textworld/generator/game.py +++ b/textworld/generator/game.py @@ -32,6 +32,23 @@ def __init__(self): super().__init__(msg) +def gen_commands_from_actions(actions): + def _get_name_mapping(action): + mapping = data.get_rules()[action.name].match(action) + return {ph.name: var.name for ph, var in mapping.items()} + + commands = [] + for action in actions: + command = "None" + if action is not None: + command = data.INFORM7_COMMANDS[action.name] + command = command.format(**_get_name_mapping(action)) + + commands.append(command) + + return commands + + class Quest: """ Quest presentation in TextWorld. @@ -42,7 +59,7 @@ class Quest: def __init__(self, actions: Optional[Iterable[Action]] = None, winning_conditions: Optional[Collection[Proposition]] = None, failing_conditions: Optional[Collection[Proposition]] = None, - desc: str = "") -> None: + desc: Optional[str] = None) -> None: """ Args: actions: The actions to be performed to complete the quest. @@ -58,7 +75,7 @@ def __init__(self, actions: Optional[Iterable[Action]] = None, """ self.actions = tuple(actions) if actions else () self.desc = desc - self.commands = [] + self.commands = gen_commands_from_actions(self.actions) self.reward = 1 self.win_action = self.set_winning_conditions(winning_conditions) self.fail_action = self.set_failing_conditions(failing_conditions) @@ -120,6 +137,7 @@ def __eq__(self, other: Any) -> bool: self.win_action == other.win_action and self.fail_action == other.fail_action and self.desc == other.desc and + self.reward == other.reward and self.commands == other.commands) @classmethod @@ -140,6 +158,7 @@ def deserialize(cls, data: Mapping) -> "Quest": desc = data["desc"] quest = cls(actions, win_action.preconditions, failing_conditions, desc=desc) quest.commands = data["commands"] + quest.reward = data.get("reward", 1) return quest def serialize(self) -> Mapping: @@ -150,6 +169,7 @@ def serialize(self) -> Mapping: """ data = {} data["desc"] = self.desc + data["reward"] = self.reward data["commands"] = self.commands data["actions"] = [action.serialize() for action in self.actions] data["win_action"] = self.win_action.serialize() @@ -248,15 +268,26 @@ def __init__(self, world: World, grammar: Optional[Grammar] = None, """ self.world = world self.state = world.state.copy() # Current state of the game. - self.grammar = grammar self.quests = [] if quests is None else quests self.metadata = {} + self._objective = None self._infos = self._build_infos() self._rules = data.get_rules() self._types = data.get_types() - # TODO: - # self.change_names() - # self.change_descriptions() + self.change_grammar(grammar) + + self._main_quest = None + + @property + def main_quest(self): + if self._main_quest is None: + from textworld.generator import inform7 + from textworld.generator.text_generation import assign_description_to_quest + self._main_quest = Quest(actions=GameProgression(self).winning_policy) + self._main_quest.desc = assign_description_to_quest(self._main_quest, self, self.grammar) + self._main_quest.commands = inform7.gen_commands_from_actions(self._main_quest.actions, self.infos) + + return self._main_quest @property def infos(self) -> Dict[str, EntityInfo]: @@ -278,6 +309,7 @@ def copy(self) -> "Game": game.state = self.state.copy() game._rules = self._rules game._types = self._types + game._objective = self._objective return game def change_grammar(self, grammar: Grammar) -> None: @@ -285,16 +317,15 @@ def change_grammar(self, grammar: Grammar) -> None: from textworld.generator import inform7 from textworld.generator.text_generation import generate_text_from_grammar self.grammar = grammar + if self.grammar is None: + return + generate_text_from_grammar(self, self.grammar) for quest in self.quests: # TODO: should have a generic way of generating text commands from actions - # insteaf of relying on inform7 convention. + # instead of relying on inform7 convention. quest.commands = inform7.gen_commands_from_actions(quest.actions, self.infos) - # TODO - # self.change_names() - # self.change_descriptions() - def save(self, filename: str) -> None: """ Saves the serialized data of this game to a file. """ with open(filename, 'w') as f: @@ -315,11 +346,11 @@ def deserialize(cls, data: Mapping) -> "Game": `Game` object. """ world = World.deserialize(data["world"]) - grammar = None + game = cls(world) if "grammar" in data: - grammar = Grammar(data["grammar"]) - quests = [Quest.deserialize(d) for d in data["quests"]] - game = cls(world, grammar, quests) + game.grammar = Grammar(data["grammar"]) + + game.quests = [Quest.deserialize(d) for d in data["quests"]] game._infos = {k: EntityInfo.deserialize(v) for k, v in data["infos"]} game.state = State.deserialize(data["state"]) @@ -327,6 +358,7 @@ def deserialize(cls, data: Mapping) -> "Game": for k, v in data["rules"]} game._types = VariableTypeTree.deserialize(data["types"]) game.metadata = data.get("metadata", {}) + game._objective = data.get("objective", None) return game @@ -340,24 +372,27 @@ 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 + data["grammar"] = self.grammar.flags.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()] data["types"] = self._types.serialize() data["metadata"] = self.metadata + data["objective"] = self._objective return data def __eq__(self, other: Any) -> bool: return (isinstance(other, Game) and self.world == other.world and self.infos == other.infos and - self.quests == other.quests) + self.quests == other.quests and + self._objective == other._objective) def __hash__(self) -> int: state = (self.world, frozenset(self.quests), - frozenset(self.infos.items())) + frozenset(self.infos.items()), + self._objective) return hash(state) @@ -403,6 +438,21 @@ def win_condition(self) -> List[Collection[Proposition]]: """ All win conditions, one for each quest. """ return [q.winning_conditions for q in self.quests] + @property + def objective(self) -> str: + if self._objective is not None: + return self._objective + + if len(self.quests) == 0: + return "" + + self._objective = self.main_quest.desc + return self._objective + + @objective.setter + def objective(self, value: str): + self._objective = value + class ActionDependencyTreeElement(DependencyTreeElement): """ Representation of an `Action` in the dependency tree. @@ -514,7 +564,7 @@ def __init__(self, quest: Quest) -> None: Args: quest: The quest to keep track of its completion. """ - self._quest = quest + self.quest = quest self._completed = False self._failed = False self._unfinishable = False @@ -567,12 +617,12 @@ def update(self, action: Optional[Action] = None, state: Optional[State] = None) if state is not None: # Check if quest is completed. - if self._quest.win_action is not None: - self._completed = state.is_applicable(self._quest.win_action) + if self.quest.win_action is not None: + self._completed = state.is_applicable(self.quest.win_action) # Check if quest has failed. - if self._quest.fail_action is not None: - self._failed = state.is_applicable(self._quest.fail_action) + if self.quest.fail_action is not None: + self._failed = state.is_applicable(self.quest.fail_action) # Try compressing the winning policy given the new game state. if self.compress_winning_policy(state): @@ -646,17 +696,33 @@ def __init__(self, game: Game, track_quests: bool = True) -> None: @property def done(self) -> bool: """ Whether all quests are completed or at least one has failed or is unfinishable. """ + return self.completed or self.failed + + @property + def completed(self) -> bool: + """ Whether all quests are completed. """ if not self.tracking_quests: - return False # There is nothing to be "done". + return False # There is nothing to be "completed". - all_completed = True - for quest_progression in self.quest_progressions: - if quest_progression.failed or quest_progression.unfinishable: - return True + return all(qp.completed for qp in self.quest_progressions) - all_completed &= quest_progression.completed + @property + def failed(self) -> bool: + """ Whether at least one quest has failed or is unfinishable. """ + if not self.tracking_quests: + return False # There is nothing to be "failed". + + return any((qp.failed or qp.unfinishable) for qp in self.quest_progressions) - return all_completed + @property + def score(self) -> int: + """ Sum of the reward of all completed quests. """ + return sum(qp.quest.reward for qp in self.quest_progressions if qp.completed) + + @property + def max_score(self) -> int: + """ Sum of the reward of all quests. """ + return sum(quest.reward for quest in self.game.quests) @property def tracking_quests(self) -> bool: diff --git a/textworld/generator/inform7/world2inform7.py b/textworld/generator/inform7/world2inform7.py index e4858744..2223e373 100644 --- a/textworld/generator/inform7/world2inform7.py +++ b/textworld/generator/inform7/world2inform7.py @@ -156,7 +156,6 @@ def generate_inform7_source(game, seed=1234, use_i7_description=False): quests = game.quests source = "" - source += "Use scoring. The maximum score is 1.\n" source += "When play begins, seed the random-number generator with {}.\n\n".format(seed) source += define_inform7_kinds() # Mention that rooms have a special text attribute called 'internal name'. @@ -221,22 +220,30 @@ def generate_inform7_source(game, seed=1234, use_i7_description=False): # Place the player. source += "The player is in {}.\n\n".format(var_infos[world.player_room.id].id) - quest = None - if len(quests) > 0: - quest = quests[0] # TODO: randomly sample a quest. + objective = game.objective + maximum_score = 0 + for quest_id, quest in enumerate(quests): commands = gen_commands_from_actions(quest.actions, var_infos) quest.commands = commands + maximum_score += quest.reward + + quest_completed = textwrap.dedent("""\ + The quest{quest_id} completed is a truth state that varies. + The quest{quest_id} completed is usually false. + """) + source += quest_completed.format(quest_id=quest_id) - walkthrough = '\nTest me with "{}"\n\n'.format(" / ".join(commands)) + walkthrough = '\nTest quest{} with "{}"\n\n'.format(quest_id, " / ".join(commands)) source += walkthrough - # Add winning and losing conditions. - ending_condition = """\ + # Add winning and losing conditions for quest. + quest_ending_condition = """\ Every turn: - if {}: + if {losing_tests}: end the story; [Lost] - else if {}: - end the story finally; [Win] + else if quest{quest_id} completed is false and {winning_tests}: + increase the score by {reward}; [Quest completed] + Now the quest{quest_id} completed is true. """ @@ -246,8 +253,41 @@ def generate_inform7_source(game, seed=1234, use_i7_description=False): if quest.fail_action is not None: losing_tests = gen_source_for_conditions(quest.fail_action.preconditions) - ending_condition = ending_condition.format(losing_tests, winning_tests) - source += textwrap.dedent(ending_condition) + quest_ending_condition = quest_ending_condition.format(losing_tests=losing_tests, + winning_tests=winning_tests, + reward=quest.reward, + quest_id=quest_id) + source += textwrap.dedent(quest_ending_condition) + + # Enable scoring is at least one quest has nonzero reward. + if maximum_score != 0: + source += "Use scoring. The maximum score is {}.\n".format(maximum_score) + + # Build test condition for winning the game. + game_winning_test = "1 is 0 [always false]" + if len(quests) > 0: + test_template = "quest{} completed is true" + game_winning_test = " and ".join(test_template.format(i) for i in range(len(quests))) + + # Remove square bracket when printing score increases. Square brackets are conflicting with + # Inform7's events parser in git_glulx_ml.py. + # And add winning conditions for the game. + source += textwrap.dedent("""\ + This is the simpler notify score changes rule: + If the score is not the last notified score: + let V be the score - the last notified score; + say "Your score has just gone up by [V in words] "; + if V > 1: + say "points."; + else: + say "point."; + Now the last notified score is the score; + if {game_winning_test}: + end the story finally; [Win] + + The simpler notify score changes rule substitutes for the notify score changes rule. + + """.format(game_winning_test=game_winning_test)) if not use_i7_description: # Remove Inform7 listing of nondescript items. @@ -292,7 +332,7 @@ def generate_inform7_source(game, seed=1234, use_i7_description=False): Rule for printing the banner text: say "{objective}[line break]". - """.format(objective=quest.desc if quest is not None else "")) + """.format(objective=objective)) # Simply display *** The End *** when game ends. source += textwrap.dedent("""\ @@ -300,7 +340,6 @@ def generate_inform7_source(game, seed=1234, use_i7_description=False): Rule for printing the player's obituary: if story has ended finally: - increase score by 1; center "*** The End ***"; else: center "*** You lost! ***"; @@ -451,7 +490,7 @@ def generate_inform7_source(game, seed=1234, use_i7_description=False): source += textwrap.dedent("""\ An objective is some text that varies. The objective is "{objective}". - """.format(objective=quest.desc if quest is not None else "")) + """.format(objective=objective)) # Special command to print the objective of the game, if any. source += textwrap.dedent("""\ @@ -618,6 +657,7 @@ def generate_inform7_source(game, seed=1234, use_i7_description=False): Turning on the restrict commands option is an action applying to nothing. Carry out turning on the restrict commands option: + Decrease turn count by 1; Now the restrict commands option is true. Understand "restrict commands" as turning on the restrict commands option. diff --git a/textworld/generator/tests/test_game.py b/textworld/generator/tests/test_game.py index 7634b0b1..334ce6de 100644 --- a/textworld/generator/tests/test_game.py +++ b/textworld/generator/tests/test_game.py @@ -48,27 +48,25 @@ def test_game_comparison(): 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, grammar_flags={}, rngs=rngs) + 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, grammar_flags={}, rngs=rngs) + game2 = make_game(world_size=5, nb_objects=5, quest_length=2, quest_breadth=2, grammar_flags={}, rngs=rngs) assert game1 == game2 # Test __eq__ assert game1 in {game2} # Test __hash__ - game3 = make_game(world_size=5, nb_objects=5, quest_length=2, grammar_flags={}, rngs=rngs) + game3 = make_game(world_size=5, nb_objects=5, quest_length=2, quest_breadth=2, grammar_flags={}, rngs=rngs) 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, grammar_flags=grammar_flags) + game = textworld.generator.make_game(world_size=5, nb_objects=10, quest_length=3, quest_breadth=2, grammar_flags=grammar_flags) 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 2a5d1ae0..9d5eed4f 100644 --- a/textworld/generator/tests/test_logger.py +++ b/textworld/generator/tests/test_logger.py @@ -18,7 +18,7 @@ def test_logger(): 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) + game = textworld.generator.make_game(world_size=5, nb_objects=10, quest_length=3, quest_breadth=3) game_logger.collect(game) with make_temp_directory(prefix="textworld_tests") as tests_folder: diff --git a/textworld/generator/tests/test_text_generation.py b/textworld/generator/tests/test_text_generation.py index 1a34c4c5..ef5f7fee 100644 --- a/textworld/generator/tests/test_text_generation.py +++ b/textworld/generator/tests/test_text_generation.py @@ -77,8 +77,10 @@ def test_blend_instructions(verbose=False): grammar2 = textworld.generator.make_grammar(flags={"blend_instructions": True}, rng=np.random.RandomState(42)) + quest.desc = None game.change_grammar(grammar1) quest1 = quest.copy() + quest.desc = None game.change_grammar(grammar2) quest2 = quest.copy() assert len(quest1.desc) > len(quest2.desc) diff --git a/textworld/generator/tests/test_text_grammar.py b/textworld/generator/tests/test_text_grammar.py index 3942727f..d78f2207 100644 --- a/textworld/generator/tests/test_text_grammar.py +++ b/textworld/generator/tests/test_text_grammar.py @@ -5,6 +5,7 @@ import unittest from textworld.generator.text_grammar import Grammar +from textworld.generator.text_grammar import GrammarFlags class ContainsEveryObjectContainer: @@ -12,6 +13,13 @@ def __contains__(self, item): return True +class TestGrammarFlags(unittest.TestCase): + def test_serialization(self): + flags = GrammarFlags() + data = flags.serialize() + flags2 = GrammarFlags.deserialize(data) + assert flags == flags2 + class GrammarTest(unittest.TestCase): def test_grammar_eq(self): grammar = Grammar() @@ -20,7 +28,7 @@ def test_grammar_eq(self): def test_grammar_eq2(self): grammar = Grammar() - grammar2 = Grammar(flags={'unused': 'flag'}) + grammar2 = Grammar(flags={'theme': 'something'}) self.assertNotEqual(grammar, grammar2, "Testing two different grammar files are not equal") def test_grammar_get_random_expansion_fail(self): diff --git a/textworld/generator/text_generation.py b/textworld/generator/text_generation.py index 89b63c16..2ba3e81b 100644 --- a/textworld/generator/text_generation.py +++ b/textworld/generator/text_generation.py @@ -6,6 +6,7 @@ from collections import OrderedDict from textworld.generator import data +from textworld.generator.game import Quest from textworld.generator.text_grammar import Grammar from textworld.generator.text_grammar import fix_determinant @@ -21,7 +22,7 @@ def __getitem__(self, item): return super().__getitem__(item) -def assign_new_matching_names(obj1_infos, obj2_infos, grammar, include_adj, exclude=[]): +def assign_new_matching_names(obj1_infos, obj2_infos, grammar, exclude=[]): tag = "#({}<->{})_match#".format(obj1_infos.type, obj2_infos.type) if not grammar.has_tag(tag): return False @@ -31,8 +32,8 @@ def assign_new_matching_names(obj1_infos, obj2_infos, grammar, include_adj, excl result = grammar.expand(tag) first, second = result.split("<->") # Matching arguments are separated by '<->'. - name1, adj1, noun1 = grammar.split_name_adj_noun(first.strip(), include_adj) - name2, adj2, noun2 = grammar.split_name_adj_noun(second.strip(), include_adj) + 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) if name1 not in exclude and name2 not in exclude and name1 != name2: found_matching_names = True break @@ -52,7 +53,7 @@ def assign_new_matching_names(obj1_infos, obj2_infos, grammar, include_adj, excl return True -def assign_name_to_object(obj, grammar, game_infos, include_adj): +def assign_name_to_object(obj, grammar, game_infos): """ Assign a name to an object (if needed). """ @@ -66,20 +67,19 @@ def assign_name_to_object(obj, grammar, game_infos, include_adj): # Check if the object should match another one (i.e. same adjective). if obj.matching_entity_id is not None: other_obj_infos = game_infos[obj.matching_entity_id] - success = assign_new_matching_names(obj_infos, other_obj_infos, grammar, include_adj, exclude) + success = assign_new_matching_names(obj_infos, other_obj_infos, grammar, exclude) if success: return # Try swapping the objects around i.e. match(o2, o1). - success = assign_new_matching_names(other_obj_infos, obj_infos, grammar, include_adj, exclude) + success = assign_new_matching_names(other_obj_infos, obj_infos, grammar, exclude) if success: return # TODO: Should we enforce it? # Fall back on generating unmatching object name. - values = grammar.generate_name(obj.type, room_type=obj_infos.room_type, - include_adj=include_adj, exclude=exclude) + values = grammar.generate_name(obj.type, room_type=obj_infos.room_type, exclude=exclude) obj_infos.name, obj_infos.adj, obj_infos.noun = values grammar.used_names.add(obj_infos.name) @@ -103,19 +103,13 @@ def assign_description_to_object(obj, grammar, game_infos): def generate_text_from_grammar(game, grammar: Grammar): - include_adj = grammar.flags.get("include_adj", False) - only_last_action = grammar.flags.get("only_last_action", False) - blend_instructions = grammar.flags.get("blend_instructions", False) - blend_descriptions = grammar.flags.get("blend_descriptions", False) - ambiguous_instructions = grammar.flags.get("ambiguous_instructions", False) - # Assign a specific room type and name to our rooms for room in game.world.rooms: # First, generate a unique roomtype and name from the grammar if game.infos[room.id].room_type is None and grammar.has_tag("#room_type#"): game.infos[room.id].room_type = grammar.expand("#room_type#") - assign_name_to_object(room, grammar, game.infos, include_adj) + assign_name_to_object(room, grammar, game.infos) # Next, assure objects contained in a room must have the same room type for obj in game.world.get_all_objects_in(room): @@ -127,38 +121,35 @@ def generate_text_from_grammar(game, grammar: Grammar): if game.infos[obj.id].room_type is None and grammar.has_tag("#room_type#"): game.infos[obj.id].room_type = grammar.expand("#room_type#") - # We have to "count" all the adj/noun/types in the world - # This is important for using "unique" but abstracted references to objects - counts = OrderedDict() - counts["adj"] = CountOrderedDict() - counts["noun"] = CountOrderedDict() - counts["type"] = CountOrderedDict() - # Assign name and description to objects. for obj in game.world.objects: if obj.type in ["I", "P"]: continue - obj_infos = game.infos[obj.id] - assign_name_to_object(obj, grammar, game.infos, include_adj) + assign_name_to_object(obj, grammar, game.infos) assign_description_to_object(obj, grammar, game.infos) - counts['adj'][obj_infos.adj] += 1 - counts['noun'][obj_infos.noun] += 1 - counts['type'][obj.type] += 1 - # Generate the room descriptions. for room in game.world.rooms: - assign_description_to_room(room, game, grammar, blend_descriptions) + if game.infos[room.id].desc is None: # Skip rooms which already have a description. + game.infos[room.id].desc = assign_description_to_room(room, game, grammar) # Generate the instructions. for quest in game.quests: - assign_description_to_quest(quest, game, grammar, counts, only_last_action, blend_instructions, ambiguous_instructions) + 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: + 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 + game.objective = assign_description_to_quest(main_quest, game, grammar) + grammar.flags.only_last_action = only_last_action_bkp return game -def assign_description_to_room(room, game, grammar, blend_descriptions): +def assign_description_to_room(room, game, grammar): """ Assign a descripton to a room. """ @@ -188,7 +179,7 @@ def assign_description_to_room(room, game, grammar, blend_descriptions): obj_infos = game.infos[obj.id] adj, noun = obj_infos.adj, obj_infos.noun - if blend_descriptions: + if grammar.flags.blend_descriptions: found = False for type in ["noun", "adj"]: group_filt = [] @@ -248,7 +239,7 @@ def assign_description_to_room(room, game, grammar, blend_descriptions): exits_desc = [] # Describing exits with door. - if blend_descriptions and len(exits_with_closed_door) > 1: + if grammar.flags.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)) @@ -263,7 +254,7 @@ def assign_description_to_room(room, game, grammar, blend_descriptions): d_desc = d_desc.replace("(dir)", dir_) exits_desc.append(d_desc) - if blend_descriptions and len(exits_with_open_door) > 1: + if grammar.flags.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)) @@ -279,7 +270,7 @@ def assign_description_to_room(room, game, grammar, blend_descriptions): exits_desc.append(d_desc) # Describing exits without door. - if blend_descriptions and len(exits_without_door) > 1: + if grammar.flags.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) @@ -291,7 +282,7 @@ def assign_description_to_room(room, game, grammar, blend_descriptions): room_desc += " ".join(exits_desc) # Finally, set the description - game.infos[room.id].desc = fix_determinant(room_desc) + return fix_determinant(room_desc) class MergeAction: @@ -308,7 +299,7 @@ def __init__(self): self.end = None -def generate_instruction(action, grammar, game_infos, world, counts, ambiguous_instructions): +def generate_instruction(action, grammar, game_infos, world, counts): """ Generate text instruction for a specific action. """ @@ -360,7 +351,7 @@ def generate_instruction(action, grammar, game_infos, world, counts, ambiguous_i obj = world.find_object_by_id(var.name) obj_infos = game_infos[obj.id] - if ambiguous_instructions: + if grammar.flags.ambiguous_instructions: assert False, "not tested" choices = [] @@ -393,29 +384,46 @@ def generate_instruction(action, grammar, game_infos, world, counts, ambiguous_i return desc, separator -def assign_description_to_quest(quest, game, grammar, counts, only_last_action, blend_instructions, ambiguous_instructions): +def assign_description_to_quest(quest, game, grammar): """ Assign a descripton to a quest. """ + # We have to "count" all the adj/noun/types in the world + # This is important for using "unique" but abstracted references to objects + counts = OrderedDict() + counts["adj"] = CountOrderedDict() + counts["noun"] = CountOrderedDict() + counts["type"] = CountOrderedDict() + + # Assign name and description to objects. + for obj in game.world.objects: + if obj.type in ["I", "P"]: + continue + + obj_infos = game.infos[obj.id] + counts['adj'][obj_infos.adj] += 1 + counts['noun'][obj_infos.noun] += 1 + counts['type'][obj.type] += 1 + if len(quest.actions) == 0: # We don't need to say anything if the quest is empty - quest.desc = "Choose your own adventure!" + quest_desc = "Choose your own adventure!" else: # Generate a description for either the last, or all commands - if only_last_action: - actions_desc, _ = generate_instruction(quest.actions[-1], grammar, game.infos, game.world, counts, ambiguous_instructions) + if grammar.flags.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 blend_instructions: + if grammar.flags.blend_instructions: instructions = get_action_chains(quest.actions, grammar, game.infos) else: instructions = quest.actions only_one_action = len(instructions) < 2 for c in instructions: - desc, separator = generate_instruction(c, grammar, game.infos, game.world, counts, ambiguous_instructions) + desc, separator = generate_instruction(c, grammar, game.infos, game.world, counts) actions_desc += desc if c != instructions[-1] and len(separator) > 0: actions_desc += separator @@ -428,7 +436,9 @@ def assign_description_to_quest(quest, game, grammar, counts, only_last_action, quest_tag = grammar.get_random_expansion("#quest#") quest_tag = quest_tag.replace("(list_of_actions)", actions_desc.strip()) - quest.desc = grammar.expand(quest_tag) + quest_desc = grammar.expand(quest_tag) + + return quest_desc def get_action_chains(actions, grammar, game_infos): diff --git a/textworld/generator/text_grammar.py b/textworld/generator/text_grammar.py index 1417a060..9637e584 100644 --- a/textworld/generator/text_grammar.py +++ b/textworld/generator/text_grammar.py @@ -36,7 +36,7 @@ def fix_determinant(var): class GrammarFlags: - __slots__ = ['theme', 'include_adj', 'blend_descriptions', + __slots__ = ['theme', 'names_to_exclude', 'include_adj', 'blend_descriptions', 'ambiguous_instructions', 'only_last_action', 'blend_instructions', 'allowed_variables_numbering', 'unique_expansion'] @@ -45,6 +45,7 @@ def __init__(self, flags=None, **kwargs): flags = flags or kwargs 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) @@ -53,18 +54,37 @@ def __init__(self, flags=None, **kwargs): self.blend_descriptions = flags.get("blend_descriptions", False) self.ambiguous_instructions = flags.get("ambiguous_instructions", False) - def encode(self): + def serialize(self) -> Mapping: + return {slot: getattr(self, slot) for slot in self.__slots__} + + @classmethod + def deserialize(cls, data: Mapping) -> "GrammarFlags": + return cls(data) + + def __eq__(self, other) -> bool: + return (isinstance(other, GrammarFlags) 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. """ - values = [int(getattr(self, s)) for s in self.__slots__[1:]] + 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)) 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)) -def encode_flags(flags): +def encode_flags(flags: Mapping) -> str: return GrammarFlags(flags).encode() @@ -83,19 +103,19 @@ def __init__(self, flags: Mapping = {}, rng: Optional[RandomState] = None): :param rng: Random generator used for sampling tag expansions. """ - self.flags = flags + self.flags = GrammarFlags(flags) self.grammar = OrderedDict() self.rng = g_rng.next() if rng is None else rng - self.allowed_variables_numbering = self.flags.get("allowed_variables_numbering", False) - self.unique_expansion = self.flags.get("unique_expansion", False) + self.allowed_variables_numbering = self.flags.allowed_variables_numbering + self.unique_expansion = self.flags.unique_expansion self.all_expansions = defaultdict(list) # The current used symbols self.overflow_dict = OrderedDict() - self.used_names = set(self.flags.get("names_to_exclude", [])) + self.used_names = set(self.flags.names_to_exclude) # Load the grammar associated to the provided theme. - self.theme = self.flags.get("theme", "house") + self.theme = self.flags.theme grammar_contents = [] # Load the object names file @@ -111,7 +131,7 @@ def __eq__(self, other): return (isinstance(other, Grammar) and self.overflow_dict == other.overflow_dict and self.grammar == other.grammar and - self.flags == other.flags and + self.flags.encode() == other.flags.encode() and self.used_names == other.used_names) def _parse(self, lines: List[str]): @@ -174,7 +194,6 @@ def get_random_expansion(self, tag: str, rng: Optional[RandomState] = None) -> s self.all_expansions[tag].append(expansion) return expansion - def expand(self, text: str, rng: Optional[RandomState] = None) -> str: """ Expand some text until there is no more tag to expand. @@ -235,7 +254,7 @@ def split_name_adj_noun(self, candidate: str, include_adj: bool) -> Optional[Tup return name, adj, noun def generate_name(self, obj_type: str, room_type: str = "", - include_adj: bool = True, exclude: Container[str] = []) -> Tuple[str, str, str]: + include_adj: Optional[bool] = None, exclude: Container[str] = []) -> Tuple[str, str, str]: """ Generate a name given an object type and the type room it belongs to. @@ -248,6 +267,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 exclude : optional List of names we should avoid generating. @@ -260,6 +280,8 @@ def generate_name(self, obj_type: str, room_type: str = "", noun : The noun part of the name. """ + if include_adj is None: + include_adj = self.flags.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 6b5b3620..4c4ee7a7 100644 --- a/textworld/helpers.py +++ b/textworld/helpers.py @@ -119,7 +119,7 @@ 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, +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]: """ Makes a text-based game. @@ -128,6 +128,7 @@ def make(world_size: int = 1, nb_objects: int = 5, quest_length: int = 2, 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. @@ -137,6 +138,6 @@ def make(world_size: int = 1, nb_objects: int = 5, quest_length: int = 2, """ g_rng.set_seed(seed) game_name = "game_{}".format(seed) - game = make_game(world_size, nb_objects, quest_length, grammar_flags) + 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) return game_file, game diff --git a/textworld/render/render.py b/textworld/render/render.py index 50da8ee0..458e77c5 100644 --- a/textworld/render/render.py +++ b/textworld/render/render.py @@ -215,18 +215,18 @@ def used_pos(): edges.append((room.name, target.name, room.doors.get(exit))) # temp_viz(nodes, edges, pos, color=[world.player_room.name]) - pos = {game_infos[k].name: v for k, v in pos.items()} rooms = {} player_room = world.player_room if game_infos is None: new_game = Game(world, []) game_infos = new_game.infos - game_infos["objective"] = new_game.quests[0].desc for k, v in game_infos.items(): if v.name is None: v.name = k + pos = {game_infos[k].name: v for k, v in pos.items()} + for room in world.rooms: rooms[room.id] = GraphRoom(game_infos[room.id].name, room) @@ -354,9 +354,7 @@ def visualize(world: Union[Game, State, GlulxGameState, World], if isinstance(world, Game): game = world state = load_state(game.world, game.infos) - state["objective"] = "" - if len(game.quests) > 0: - state["objective"] = game.quests[0].desc + state["objective"] = game.objective elif isinstance(world, GlulxGameState): state = load_state_from_game_state(game_state=world) elif isinstance(world, World):