From 66a8ba9536c232dc6d71f72865d4e65605ea822b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:03:56 +0000 Subject: [PATCH 01/15] Initial plan From f9dcaccaac7f3eb72e04a8061bc1061334ba3b46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:14:10 +0000 Subject: [PATCH 02/15] Refactor dungeon functionality into reusable Dungeon class Co-authored-by: neph1 <7988802+neph1@users.noreply.github.com> --- stories/dungeon/story.py | 99 ++------ stories/dungeon_example/story.py | 114 +++++++++ stories/dungeon_example/story_config.json | 25 ++ stories/dungeon_example/world.json | 42 +++ tale/dungeon/dungeon.py | 295 ++++++++++++++++++++++ tests/test_dungeon.py | 181 +++++++++++++ 6 files changed, 676 insertions(+), 80 deletions(-) create mode 100644 stories/dungeon_example/story.py create mode 100644 stories/dungeon_example/story_config.json create mode 100644 stories/dungeon_example/world.json create mode 100644 tale/dungeon/dungeon.py create mode 100644 tests/test_dungeon.py diff --git a/stories/dungeon/story.py b/stories/dungeon/story.py index c5661c72..fff891bf 100644 --- a/stories/dungeon/story.py +++ b/stories/dungeon/story.py @@ -7,7 +7,9 @@ from tale import lang from tale.base import Door, Exit, Location from tale.charbuilder import PlayerNaming +from tale.coord import Coord from tale.driver import Driver +from tale.dungeon.dungeon import Dungeon from tale.dungeon.dungeon_generator import ItemPopulator, Layout, LayoutGenerator, MobPopulator from tale.items.basic import Money from tale.json_story import JsonStory @@ -31,10 +33,21 @@ def __init__(self, path = '', layout_generator = LayoutGenerator(), mob_populato self.item_populator = item_populator self.max_depth = 5 self.depth = 0 + self.dungeon = None # Will be created after init def init(self, driver: Driver) -> None: self.llm_util = driver.llm_util + # Create the dungeon instance BEFORE calling super().init() + self.dungeon = Dungeon( + name="The Depths", + story=self, + llm_util=self.llm_util, + layout_generator=self.layout_generator, + mob_populator=self.mob_populator, + item_populator=self.item_populator, + max_depth=self.max_depth + ) super(Story, self).init(driver) def init_player(self, player: Player) -> None: @@ -104,88 +117,14 @@ def add_zone(self, zone: Zone) -> bool: return False if zone.locations != {}: return True - first_zone = len(self._zones.values()) == 0 - zone.size_z = 1 - layout = self.layout_generator.generate() - - rooms = self._prepare_locations(layout=layout, first_zone=first_zone) - - self._describe_rooms(zone=zone, layout=layout, rooms=rooms) - self._connect_locations(layout=layout) - - mob_spawners = self.mob_populator.populate(zone=zone, layout=layout, story=self) - for mob_spawner in mob_spawners: - self.world.add_mob_spawner(mob_spawner) - - item_spawners = self.item_populator.populate(zone=zone, story=self) - for item_spawner in item_spawners: - self.world.add_item_spawner(item_spawner) - - if zone.center.z == self.max_depth: - self.llm_util.generate_character - - if not first_zone: - self.layout_generator.spawn_gold(zone=zone) - + # Use the dungeon to generate the level + if self.dungeon: + depth = len(self.dungeon.zones) + self.depth = depth + self.dungeon.generate_level(zone, depth=depth) + return True - - def _describe_rooms(self, zone: Zone, layout: Layout, rooms: list): - described_rooms = [] - sliced_rooms = [] - for num in range(0, len(rooms), 10): - sliced_rooms.extend(rooms[num:num+10]) - for i in range(3): - described_rooms_slice = self.llm_util.generate_dungeon_locations(zone_info=zone.get_info(), locations=sliced_rooms, depth = self.depth, max_depth=self.max_depth) # type LocationDescriptionResponse - if described_rooms_slice.valid: - described_rooms.extend(described_rooms_slice.location_descriptions) - sliced_rooms = [] - break - if len(rooms) != len(described_rooms): - print(f'Rooms list not same length: {len(rooms)} vs {len(described_rooms)}') - for room in described_rooms: - i = 1 - if zone.get_location(room.name): - # ensure unique names - room.name = f'{room.name}({i})' - i += 1 - location = Location(name=room.name, descr=room.description) - location.world_location = list(layout.cells.values())[room.index].coord - zone.add_location(location=location) - self.add_location(zone=zone.name, location=location) - return described_rooms - - - def _prepare_locations(self, layout: Layout, first_zone: bool = False) -> list: - index = 0 - rooms = [] - for cell in list(layout.cells.values()): - if cell.is_dungeon_entrance: - rooms.append(f'{{"index": {index}, "name": "Entrance to dungeon"}}') - if cell.is_entrance: - rooms.append(f'{{"index": {index}, "name": "Room with pathway leading up to this level."}}') - elif cell.is_exit: - rooms.append(f'{{"index": {index}, "name": "Room with pathway leading down"}}') - elif cell.is_room: - rooms.append(f'{{"index": {index}, "name": "Room"}}') - else: - rooms.append(f'{{"index": {index}, "name": "Hallway", "description": "A hallway"}}') - index += 1 - return rooms - - def _connect_locations(self, layout: Layout) -> None: - connections = layout.connections - for connection in connections: - cell_location = self.world._grid.get(connection.coord.as_tuple(), None) # type: Location - parent_location = self.world._grid.get(connection.other.as_tuple(), None) # type: Location - if cell_location.exits.get(parent_location.name, None): - continue - elif parent_location.exits.get(cell_location.name, None): - continue - if connection.door: - Door.connect(cell_location, parent_location.name, '', None, parent_location, cell_location.name, '', None, opened=False, locked=connection.locked, key_code=connection.key_code) - else: - Exit.connect(cell_location, parent_location.name, '', None, parent_location, cell_location.name, '', None) def _generate_boss(self, zone: Zone) -> bool: character = self.llm_util.generate_character(keywords=['final boss']) # Characterv2 diff --git a/stories/dungeon_example/story.py b/stories/dungeon_example/story.py new file mode 100644 index 00000000..b7342390 --- /dev/null +++ b/stories/dungeon_example/story.py @@ -0,0 +1,114 @@ +""" +Example story demonstrating a dungeon entrance in a normal location. + +This story shows how dungeons can be integrated into any story, +not just dungeon-specific stories. +""" + +import pathlib +import sys + +from tale import parse_utils +from tale.base import Location +from tale.coord import Coord +from tale.driver import Driver +from tale.dungeon.dungeon import Dungeon, DungeonEntrance +from tale.dungeon.dungeon_generator import ItemPopulator, LayoutGenerator, MobPopulator +from tale.json_story import JsonStory +from tale.main import run_from_cmdline +from tale.player import Player +from tale.zone import Zone + + +class Story(JsonStory): + """Example story with a normal location that has a dungeon entrance.""" + + driver = None + + def __init__(self) -> None: + config = parse_utils.load_story_config(parse_utils.load_json('story_config.json')) + super(Story, self).__init__('', config) + self.dungeon = None + + def init(self, driver: Driver) -> None: + """Initialize the story and create the dungeon.""" + super(Story, self).init(driver) + + # Create a dungeon + self.dungeon = Dungeon( + name="Ancient Crypt", + story=self, + llm_util=driver.llm_util, + layout_generator=LayoutGenerator(), + mob_populator=MobPopulator(), + item_populator=ItemPopulator(), + max_depth=3 + ) + + # Create the town zone with a normal location + self._create_town() + + def _create_town(self): + """Create a simple town with a dungeon entrance.""" + # Create town zone + town_zone = Zone("town", "A peaceful town") + town_zone.level = 1 + town_zone.center = Coord(0, 0, 0) + town_zone.races = ["human"] + town_zone.items = ["torch", "Sword"] + + # Create town square location + town_square = Location( + "Town Square", + "A bustling town square with a fountain in the center. " + "To the north, you see an old stone archway leading down into darkness." + ) + town_square.world_location = Coord(0, 0, 0) + + # Add location to zone + town_zone.add_location(town_square) + self.add_zone(town_zone) + self.add_location(town_square, zone="town") + + # Create a dungeon entrance in the town square + dungeon_entrance = DungeonEntrance( + directions=["north", "down", "crypt"], + dungeon=self.dungeon, + short_descr="An ancient stone archway descends into darkness.", + long_descr="The archway is covered in moss and strange runes. " + "A cold wind blows from the depths below." + ) + + # Bind the entrance to the location (this will generate the first dungeon level) + dungeon_entrance.bind(town_square) + town_square.add_exits([dungeon_entrance]) + + def welcome(self, player: Player) -> str: + """Welcome text when player enters a new game.""" + player.tell("Welcome to the Town of Mysteries!", end=True) + player.tell("\n") + player.tell("You stand in the town square. Locals speak of an ancient crypt " + "beneath the town, filled with treasures and dangers.") + player.tell("\n") + return "" + + def welcome_savegame(self, player: Player) -> str: + """Welcome text when player loads a saved game.""" + player.tell("Welcome back to the Town of Mysteries!", end=True) + player.tell("\n") + return "" + + def goodbye(self, player: Player) -> None: + """Goodbye text when player quits the game.""" + player.tell("Farewell, brave adventurer. May we meet again!") + player.tell("\n") + + +if __name__ == "__main__": + # Story is invoked as a script, start it in the Tale Driver. + gamedir = pathlib.Path(__file__).parent + if gamedir.is_dir() or gamedir.is_file(): + cmdline_args = sys.argv[1:] + cmdline_args.insert(0, "--game") + cmdline_args.insert(1, str(gamedir)) + run_from_cmdline(cmdline_args) diff --git a/stories/dungeon_example/story_config.json b/stories/dungeon_example/story_config.json new file mode 100644 index 00000000..4ad0d50b --- /dev/null +++ b/stories/dungeon_example/story_config.json @@ -0,0 +1,25 @@ +{ + "name": "Town of Mysteries", + "author": "LlamaTale Example", + "author_address": "", + "version": "1.0", + "requires_tale": "4.0", + "supported_modes": ["if"], + "player_money": 50.0, + "money_type": "modern", + "server_tick_method": "timer", + "server_tick_time": 1.0, + "gametime_to_realtime": 1, + "max_load_time": 60.0, + "display_gametime": true, + "startlocation_player": "Town Square", + "startlocation_wizard": "Town Square", + "zones": ["town"], + "context": "A peaceful town with an ancient crypt beneath it.", + "type": "fantasy", + "world_mood": 5, + "world_info": { + "races": ["human", "elf"], + "items": ["torch", "Sword"] + } +} diff --git a/stories/dungeon_example/world.json b/stories/dungeon_example/world.json new file mode 100644 index 00000000..6a9bc5c6 --- /dev/null +++ b/stories/dungeon_example/world.json @@ -0,0 +1,42 @@ +{ + "story": { + "name": "Town of Mysteries" + }, + "zones": {}, + "world": { + "npcs": {}, + "items": {}, + "spawners": [], + "item_spawners": [] + }, + "catalogue": { + "items": [ + { + "name": "torch", + "type": "light", + "value": 5, + "description": "A simple wooden torch" + }, + { + "name": "Sword", + "type": "weapon", + "value": 50, + "description": "A steel sword" + } + ], + "creatures": [ + { + "name": "bat", + "level": 1, + "aggressive": true, + "description": "A small cave bat" + }, + { + "name": "wolf", + "level": 2, + "aggressive": true, + "description": "A grey wolf" + } + ] + } +} diff --git a/tale/dungeon/dungeon.py b/tale/dungeon/dungeon.py new file mode 100644 index 00000000..39c8f473 --- /dev/null +++ b/tale/dungeon/dungeon.py @@ -0,0 +1,295 @@ +""" +Reusable dungeon implementation for LlamaTale. + +Dungeons can be attached to any story and provide procedurally generated +levels with rooms, corridors, mobs, and loot. +""" + +import random +from typing import TYPE_CHECKING + +from tale.base import Door, Exit, Location +from tale.coord import Coord +from tale.dungeon.dungeon_generator import ItemPopulator, Layout, LayoutGenerator, MobPopulator +from tale.zone import Zone + +if TYPE_CHECKING: + from tale.llm.dynamic_story import DynamicStory + + +class Dungeon: + """ + A self-contained dungeon that can be attached to any story. + + Dungeons generate procedural levels with rooms, corridors, doors, + keys, mobs, and items. They maintain their own zones and are + connected to the main world via a DungeonEntrance. + """ + + def __init__(self, + name: str, + story: 'DynamicStory', + llm_util=None, + layout_generator: LayoutGenerator = None, + mob_populator: MobPopulator = None, + item_populator: ItemPopulator = None, + max_depth: int = 5): + """ + Initialize a dungeon. + + Args: + name: Name of the dungeon + story: The story this dungeon belongs to + llm_util: LLM utility for generating descriptions (optional) + layout_generator: Generator for dungeon layouts (optional) + mob_populator: Populator for mobs (optional) + item_populator: Populator for items (optional) + max_depth: Maximum depth/levels of the dungeon + """ + self.name = name + self.story = story + self.llm_util = llm_util + self.layout_generator = layout_generator or LayoutGenerator() + self.mob_populator = mob_populator or MobPopulator() + self.item_populator = item_populator or ItemPopulator() + self.max_depth = max_depth + self.current_depth = 0 + self.zones = [] # type: list[Zone] + + def generate_level(self, zone: Zone, depth: int = 0) -> bool: + """ + Generate a single dungeon level. + + Args: + zone: The zone to populate with dungeon content + depth: The current depth level + + Returns: + True if generation was successful + """ + if zone.locations: + # Zone already has locations, don't regenerate + return True + + self.current_depth = depth + zone.size_z = 1 + + # Determine starting coordinate based on depth + start_coord = Coord(0, 0, depth) + + # Generate the layout + layout = self.layout_generator.generate(start_coord=start_coord) + + # Prepare location descriptions + rooms = self._prepare_locations(layout=layout, first_zone=(depth == 0)) + + # Generate room descriptions + self._describe_rooms(zone=zone, layout=layout, rooms=rooms) + + # Connect the locations + self._connect_locations(layout=layout) + + # Populate with mobs + mob_spawners = self.mob_populator.populate(zone=zone, layout=layout, story=self.story) + for mob_spawner in mob_spawners: + self.story.world.add_mob_spawner(mob_spawner) + + # Populate with items + item_spawners = self.item_populator.populate(zone=zone, story=self.story) + for item_spawner in item_spawners: + self.story.world.add_item_spawner(item_spawner) + + # Add gold if not the first level + if depth > 0: + self._spawn_gold(zone=zone) + + self.zones.append(zone) + return True + + def _prepare_locations(self, layout: Layout, first_zone: bool = False) -> list: + """Prepare location data for description generation.""" + index = 0 + rooms = [] + for cell in list(layout.cells.values()): + if cell.is_dungeon_entrance: + rooms.append(f'{{"index": {index}, "name": "Entrance to dungeon"}}') + elif cell.is_entrance: + rooms.append(f'{{"index": {index}, "name": "Room with pathway leading up to this level."}}') + elif cell.is_exit: + rooms.append(f'{{"index": {index}, "name": "Room with pathway leading down"}}') + elif cell.is_room: + rooms.append(f'{{"index": {index}, "name": "Room"}}') + else: + rooms.append(f'{{"index": {index}, "name": "Hallway", "description": "A hallway"}}') + index += 1 + return rooms + + def _describe_rooms(self, zone: Zone, layout: Layout, rooms: list): + """Generate descriptions for rooms using LLM.""" + described_rooms = [] + sliced_rooms = [] + + # Check if we have llm_util available + if not self.llm_util: + # Fallback to basic descriptions if no LLM available + import json + for i, room_json in enumerate(rooms): + room_data = json.loads(room_json) + room_name = room_data.get("name", f"Room {i}") + + # Ensure unique names + name_counter = 1 + unique_name = room_name + while zone.get_location(unique_name): + unique_name = f"{room_name}({name_counter})" + name_counter += 1 + + location = Location( + name=unique_name, + descr=room_data.get("description", "A dungeon room.") + ) + location.world_location = list(layout.cells.values())[i].coord + zone.add_location(location=location) + self.story.add_location(zone=zone.name, location=location) + return + + # Process rooms in batches of 10 + for num in range(0, len(rooms), 10): + sliced_rooms.extend(rooms[num:num+10]) + for i in range(3): + described_rooms_slice = self.llm_util.generate_dungeon_locations( + zone_info=zone.get_info(), + locations=sliced_rooms, + depth=self.current_depth, + max_depth=self.max_depth + ) + if described_rooms_slice.valid: + described_rooms.extend(described_rooms_slice.location_descriptions) + sliced_rooms = [] + break + + if len(rooms) != len(described_rooms): + print(f'Rooms list not same length: {len(rooms)} vs {len(described_rooms)}') + + # Create location objects + for room in described_rooms: + i = 1 + room_name = room.name + while zone.get_location(room_name): + # Ensure unique names + room_name = f'{room.name}({i})' + i += 1 + + location = Location(name=room_name, descr=room.description) + location.world_location = list(layout.cells.values())[room.index].coord + zone.add_location(location=location) + self.story.add_location(zone=zone.name, location=location) + + return described_rooms + + def _connect_locations(self, layout: Layout) -> None: + """Connect locations based on the layout.""" + connections = layout.connections + for connection in connections: + cell_location = self.story.world._grid.get(connection.coord.as_tuple(), None) + parent_location = self.story.world._grid.get(connection.other.as_tuple(), None) + + if not cell_location or not parent_location: + continue + + # Skip if already connected + if cell_location.exits.get(parent_location.name, None): + continue + elif parent_location.exits.get(cell_location.name, None): + continue + + # Create connection + if connection.door: + Door.connect( + cell_location, parent_location.name, '', None, + parent_location, cell_location.name, '', None, + opened=False, locked=connection.locked, key_code=connection.key_code + ) + else: + Exit.connect( + cell_location, parent_location.name, '', None, + parent_location, cell_location.name, '', None + ) + + def _spawn_gold(self, zone: Zone): + """Spawn gold containers in the zone.""" + from tale.base import Container + from tale.items.basic import Money + + max_gold = 5 + container_names = ["Box", "Pouch", "Chest", "Bag"] + + for i in range(max_gold): + money = Money(name="money", value=random.randrange(5, 25) * zone.level) + container = Container(random.choice(container_names)) + container.init_inventory([money]) + location = random.choice(list(zone.locations.values())) + location.insert(container, None) + + def get_entrance_location(self) -> Location: + """ + Get the entrance location of the dungeon. + + Returns: + The first location in the first zone (entrance) + """ + if not self.zones or not self.zones[0].locations: + return None + return list(self.zones[0].locations.values())[0] + + +class DungeonEntrance(Exit): + """ + A special exit that leads to a dungeon. + + This can be added to any normal location to provide access to a dungeon. + """ + + def __init__(self, directions: list, dungeon: Dungeon, + short_descr: str = "A dark entrance leads into a dungeon.", + long_descr: str = ""): + """ + Create a dungeon entrance. + + Args: + directions: Direction names to use for this exit + dungeon: The dungeon this entrance leads to + short_descr: Short description of the entrance + long_descr: Long description of the entrance + """ + self.dungeon = dungeon + # Use a placeholder string target that will be resolved later + super().__init__(directions, "__dungeon_placeholder__", short_descr, long_descr) + self._dungeon_bound = False + + def bind(self, from_location: Location): + """ + Bind the entrance to a location and generate the first dungeon level. + + Args: + from_location: The location this entrance is in + """ + if self._dungeon_bound: + return + + # Create the first zone for the dungeon + zone = Zone(f"{self.dungeon.name}_level_0", f"Level 0 of {self.dungeon.name}") + zone.level = 1 + zone.center = Coord(0, 0, 0) + + # Add zone to story + self.dungeon.story.add_zone(zone) + + # Generate the first level + self.dungeon.generate_level(zone, depth=0) + + # Get the entrance location + entrance_loc = self.dungeon.get_entrance_location() + if entrance_loc: + self.target = entrance_loc + self._dungeon_bound = True diff --git a/tests/test_dungeon.py b/tests/test_dungeon.py new file mode 100644 index 00000000..a70e2a9a --- /dev/null +++ b/tests/test_dungeon.py @@ -0,0 +1,181 @@ +""" +Tests for the reusable Dungeon class. +""" + +import datetime +from mock import MagicMock + +from tale import parse_utils, util +from tale.base import Location +from tale.coord import Coord +from tale.driver_if import IFDriver +from tale.dungeon.dungeon import Dungeon, DungeonEntrance +from tale.dungeon.dungeon_generator import Cell, Connection, Layout, LayoutGenerator, MobPopulator, ItemPopulator +from tale.json_story import JsonStory +from tale.llm.llm_utils import LlmUtil +from tale.zone import Zone +from tests.supportstuff import FakeIoUtil + + +class TestDungeon: + """Test the reusable Dungeon class.""" + + def setup_method(self): + """Set up test fixtures.""" + driver = IFDriver(screen_delay=99, gui=False, web=True, wizard_override=True) + driver.game_clock = util.GameDateTime(datetime.datetime(year=2023, month=1, day=1), 1) + + self.llm_util = LlmUtil(FakeIoUtil(response=['''{ + "0": { + "index": 0, + "name": "Entrance to dungeon", + "description": "A dark entrance." + }, + "1": { + "index": 1, + "name": "Hallway", + "description": "A long hallway." + }, + "2": { + "index": 2, + "name": "Room", + "description": "A small room." + } + }'''])) + + driver.llm_util = self.llm_util + + self.story = JsonStory( + 'tests/files/empty_world/', + parse_utils.load_story_config(parse_utils.load_json('tests/files/empty_world/story_config.json')) + ) + self.llm_util.set_story(self.story) + self.story.init(driver=driver) + + def get_mock_layout(self) -> Layout: + """Create a mock layout for testing.""" + layout = Layout(Coord(0, 0, 0)) + coords = [Coord(0, 0, 0), Coord(1, 0, 0), Coord(2, 0, 0)] + for coord in coords: + cell = Cell(coord=coord) + layout.cells[coord.as_tuple()] = cell + + layout.cells[Coord(0, 0, 0).as_tuple()].is_dungeon_entrance = True + layout.cells[Coord(1, 0, 0).as_tuple()].is_room = True + layout.cells[Coord(2, 0, 0).as_tuple()].is_room = True + layout.cells[Coord(2, 0, 0).as_tuple()].leaf = True + + layout.cells[Coord(1, 0, 0).as_tuple()].parent = Coord(0, 0, 0) + layout.cells[Coord(2, 0, 0).as_tuple()].parent = Coord(1, 0, 0) + + connection = Connection(Coord(1, 0, 0), Coord(0, 0, 0)) + layout.connections.add(connection) + + return layout + + def test_dungeon_creation(self): + """Test creating a dungeon.""" + mock_layout_generator = MagicMock() + mock_layout_generator.generate.return_value = self.get_mock_layout() + + dungeon = Dungeon( + name="Test Dungeon", + story=self.story, + layout_generator=mock_layout_generator, + mob_populator=MobPopulator(), + item_populator=ItemPopulator(), + max_depth=3 + ) + + assert dungeon.name == "Test Dungeon" + assert dungeon.max_depth == 3 + assert len(dungeon.zones) == 0 + + def test_dungeon_generate_level(self): + """Test generating a dungeon level.""" + mock_layout_generator = MagicMock() + mock_layout_generator.generate.return_value = self.get_mock_layout() + + dungeon = Dungeon( + name="Test Dungeon", + story=self.story, + layout_generator=mock_layout_generator, + mob_populator=MobPopulator(), + item_populator=ItemPopulator(), + max_depth=3 + ) + + zone = Zone("test_level_0", "Test Level 0") + zone.level = 1 + zone.center = Coord(0, 0, 0) + zone.races = ["bat"] + zone.items = ["torch"] + + self.story.add_zone(zone) + + result = dungeon.generate_level(zone, depth=0) + + assert result is True + assert len(zone.locations) == 3 + assert len(dungeon.zones) == 1 + + def test_dungeon_entrance(self): + """Test creating and binding a dungeon entrance.""" + mock_layout_generator = MagicMock() + mock_layout_generator.generate.return_value = self.get_mock_layout() + + dungeon = Dungeon( + name="Test Dungeon", + story=self.story, + layout_generator=mock_layout_generator, + mob_populator=MobPopulator(), + item_populator=ItemPopulator(), + max_depth=3 + ) + + # Create a location to add the entrance to + location = Location("Test Location", "A test location") + location.world_location = Coord(0, 0, 0) + + # Create the entrance + entrance = DungeonEntrance( + directions=["down"], + dungeon=dungeon, + short_descr="A dark entrance" + ) + + # Bind the entrance + entrance.bind(location) + + # Verify the dungeon was created + assert entrance.target is not None + assert len(dungeon.zones) == 1 + assert dungeon.zones[0].name == "Test Dungeon_level_0" + + def test_dungeon_get_entrance_location(self): + """Test getting the entrance location of a dungeon.""" + mock_layout_generator = MagicMock() + mock_layout_generator.generate.return_value = self.get_mock_layout() + + dungeon = Dungeon( + name="Test Dungeon", + story=self.story, + layout_generator=mock_layout_generator, + mob_populator=MobPopulator(), + item_populator=ItemPopulator(), + max_depth=3 + ) + + zone = Zone("test_level_0", "Test Level 0") + zone.level = 1 + zone.center = Coord(0, 0, 0) + zone.races = ["bat"] + zone.items = ["torch"] + + self.story.add_zone(zone) + dungeon.generate_level(zone, depth=0) + + entrance_location = dungeon.get_entrance_location() + + assert entrance_location is not None + assert entrance_location.name in zone.locations From 70cf5320d3f9fc7a7d583f925fef5feb6a003553 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:20:07 +0000 Subject: [PATCH 03/15] Complete dungeon refactoring with working example story and tests Co-authored-by: neph1 <7988802+neph1@users.noreply.github.com> --- stories/dungeon_example/llm_cache.json | 1 + stories/dungeon_example/story.py | 25 ++++-- stories/dungeon_example/story_config.json | 10 ++- tale/dungeon/dungeon.py | 8 +- tale/dungeon/dungeon_generator.py | 6 +- tests/test_dungeon_example_story.py | 96 +++++++++++++++++++++++ 6 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 stories/dungeon_example/llm_cache.json create mode 100644 tests/test_dungeon_example_story.py diff --git a/stories/dungeon_example/llm_cache.json b/stories/dungeon_example/llm_cache.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/stories/dungeon_example/llm_cache.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/stories/dungeon_example/story.py b/stories/dungeon_example/story.py index b7342390..a3fec028 100644 --- a/stories/dungeon_example/story.py +++ b/stories/dungeon_example/story.py @@ -25,9 +25,13 @@ class Story(JsonStory): driver = None - def __init__(self) -> None: - config = parse_utils.load_story_config(parse_utils.load_json('story_config.json')) - super(Story, self).__init__('', config) + def __init__(self, path: str = '') -> None: + if not path: + # If no path provided, use the directory containing this file + import os + path = os.path.dirname(os.path.abspath(__file__)) + '/' + config = parse_utils.load_story_config(parse_utils.load_json(path + 'story_config.json')) + super(Story, self).__init__(path, config) self.dungeon = None def init(self, driver: Driver) -> None: @@ -57,6 +61,15 @@ def _create_town(self): town_zone.races = ["human"] town_zone.items = ["torch", "Sword"] + # Also set these on the dungeon zones + # (They need to be set for mob/item population) + if self.dungeon: + # Pre-configure the dungeon zone template + self.dungeon_zone_template = { + "races": ["bat", "wolf"], + "items": ["torch"] + } + # Create town square location town_square = Location( "Town Square", @@ -79,9 +92,11 @@ def _create_town(self): "A cold wind blows from the depths below." ) - # Bind the entrance to the location (this will generate the first dungeon level) - dungeon_entrance.bind(town_square) + # Add the entrance to the location first town_square.add_exits([dungeon_entrance]) + + # Then bind the entrance (this will generate the first dungeon level) + dungeon_entrance.bind(town_square) def welcome(self, player: Player) -> str: """Welcome text when player enters a new game.""" diff --git a/stories/dungeon_example/story_config.json b/stories/dungeon_example/story_config.json index 4ad0d50b..f523fe05 100644 --- a/stories/dungeon_example/story_config.json +++ b/stories/dungeon_example/story_config.json @@ -4,10 +4,13 @@ "author_address": "", "version": "1.0", "requires_tale": "4.0", - "supported_modes": ["if"], + "supported_modes": ["IF"], + "player_name": "", + "player_gender": "m", + "player_race": "human", "player_money": 50.0, - "money_type": "modern", - "server_tick_method": "timer", + "money_type": "MODERN", + "server_tick_method": "TIMER", "server_tick_time": 1.0, "gametime_to_realtime": 1, "max_load_time": 60.0, @@ -15,6 +18,7 @@ "startlocation_player": "Town Square", "startlocation_wizard": "Town Square", "zones": ["town"], + "server_mode": "IF", "context": "A peaceful town with an ancient crypt beneath it.", "type": "fantasy", "world_mood": 5, diff --git a/tale/dungeon/dungeon.py b/tale/dungeon/dungeon.py index 39c8f473..92e3c108 100644 --- a/tale/dungeon/dungeon.py +++ b/tale/dungeon/dungeon.py @@ -281,6 +281,9 @@ def bind(self, from_location: Location): zone = Zone(f"{self.dungeon.name}_level_0", f"Level 0 of {self.dungeon.name}") zone.level = 1 zone.center = Coord(0, 0, 0) + # Set default creatures and items for the dungeon + zone.races = ["bat", "wolf"] + zone.items = ["torch"] # Add zone to story self.dungeon.story.add_zone(zone) @@ -288,8 +291,11 @@ def bind(self, from_location: Location): # Generate the first level self.dungeon.generate_level(zone, depth=0) - # Get the entrance location + # Get the entrance location and update the target entrance_loc = self.dungeon.get_entrance_location() if entrance_loc: self.target = entrance_loc self._dungeon_bound = True + + # Call the parent bind method to actually add the exit to the location + super().bind(from_location) diff --git a/tale/dungeon/dungeon_generator.py b/tale/dungeon/dungeon_generator.py index 429a2316..bf3737ea 100644 --- a/tale/dungeon/dungeon_generator.py +++ b/tale/dungeon/dungeon_generator.py @@ -212,8 +212,12 @@ def populate(self, layout: 'Layout', story: DynamicStory, zone: Zone) -> list: return [] mob_spawners = [] leaves = layout.get_leaves() + # Filter leaves to only those that have locations in the grid + valid_leaves = [cell for cell in leaves if cell.coord.as_tuple() in story.grid] + if not valid_leaves: + return [] for i in range(self.max_spawns): - cell = random.choice(leaves) + cell = random.choice(valid_leaves) location = story.grid[cell.coord.as_tuple()] mob_type = story.get_catalogue.get_creature(random.choice(zone.races)) if not mob_type: diff --git a/tests/test_dungeon_example_story.py b/tests/test_dungeon_example_story.py new file mode 100644 index 00000000..4bbf7fbc --- /dev/null +++ b/tests/test_dungeon_example_story.py @@ -0,0 +1,96 @@ +""" +Test the dungeon example story. +""" + +import datetime +from mock import MagicMock + +from tale import parse_utils, util +from tale.coord import Coord +from tale.driver_if import IFDriver +from tale.dungeon.dungeon_generator import LayoutGenerator, MobPopulator, ItemPopulator +from tale.llm.llm_utils import LlmUtil +from tests.supportstuff import FakeIoUtil + + +class TestDungeonExampleStory: + """Test the dungeon example story.""" + + def test_load_dungeon_example_story(self): + """Test that the dungeon example story loads correctly.""" + # Import story locally to avoid module-level issues + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../stories/dungeon_example')) + + from stories.dungeon_example.story import Story + + driver = IFDriver(screen_delay=99, gui=False, web=True, wizard_override=True) + driver.game_clock = util.GameDateTime(datetime.datetime(year=2023, month=1, day=1), 1) + + # Set up fake LLM responses for room descriptions + llm_util = LlmUtil(FakeIoUtil(response=['''{ + "0": { + "index": 0, + "name": "Entrance to dungeon", + "description": "A dark and ominous entrance to the ancient crypt." + }, + "1": { + "index": 1, + "name": "Hallway", + "description": "A long hallway filled with cobwebs." + }, + "2": { + "index": 2, + "name": "Crypt Chamber", + "description": "An old burial chamber." + } + }'''])) + + driver.llm_util = llm_util + + story = Story() + llm_util.set_story(story) + story.init(driver=driver) + + # Verify the story loaded + assert story.config.name == "Town of Mysteries" + + # Verify the town zone was created + assert "town" in story._zones + town_zone = story._zones["town"] + assert town_zone.name == "town" + + # Verify the town square exists + town_square = town_zone.get_location("Town Square") + assert town_square is not None + assert town_square.world_location == Coord(0, 0, 0) + + # Verify the dungeon entrance exists + exits = town_square.exits + assert len(exits) > 0 + + # The dungeon entrance should be accessible via one of these directions + dungeon_exit = None + for direction in ["north", "down", "crypt"]: + if direction in exits: + dungeon_exit = exits[direction] + break + + assert dungeon_exit is not None, "Dungeon entrance not found in town square" + + # Verify the dungeon was created + assert story.dungeon is not None + assert story.dungeon.name == "Ancient Crypt" + assert story.dungeon.max_depth == 3 + + # Verify the first dungeon level was generated + assert len(story.dungeon.zones) == 1 + dungeon_zone = story.dungeon.zones[0] + assert dungeon_zone.name == "Ancient Crypt_level_0" + + # Verify dungeon has locations + assert len(dungeon_zone.locations) > 0 + + print(f"Story loaded successfully with {len(town_zone.locations)} town location(s)") + print(f"Dungeon has {len(dungeon_zone.locations)} location(s) in level 0") From daafcf7f09ccd0ae5792e45bfa078eda92e44e7b Mon Sep 17 00:00:00 2001 From: rickard Date: Fri, 14 Nov 2025 21:45:29 +0100 Subject: [PATCH 04/15] dungeons refactor --- stories/dungeon_example/story.py | 60 ----------------- stories/dungeon_example/story_config.json | 4 +- stories/dungeon_example/world.json | 59 +++++++++++++++- tale/driver.py | 1 + tale/dungeon/dungeon.py | 56 ++++++---------- tale/json_story.py | 3 +- tale/parse/parse_locations.py | 82 +++++++++++++++++++++++ tale/parse_utils.py | 61 +---------------- tests/files/test_dungeon_entrance.json | 18 +++++ tests/parse/__init__.py | 7 ++ tests/parse/test_parse_locations.py | 34 ++++++++++ tests/test_dungeon_example_story.py | 18 ++--- tests/test_parse_utils.py | 17 +---- 13 files changed, 231 insertions(+), 189 deletions(-) create mode 100644 tale/parse/parse_locations.py create mode 100644 tests/files/test_dungeon_entrance.json create mode 100644 tests/parse/__init__.py create mode 100644 tests/parse/test_parse_locations.py diff --git a/stories/dungeon_example/story.py b/stories/dungeon_example/story.py index a3fec028..4ff4933a 100644 --- a/stories/dungeon_example/story.py +++ b/stories/dungeon_example/story.py @@ -38,66 +38,6 @@ def init(self, driver: Driver) -> None: """Initialize the story and create the dungeon.""" super(Story, self).init(driver) - # Create a dungeon - self.dungeon = Dungeon( - name="Ancient Crypt", - story=self, - llm_util=driver.llm_util, - layout_generator=LayoutGenerator(), - mob_populator=MobPopulator(), - item_populator=ItemPopulator(), - max_depth=3 - ) - - # Create the town zone with a normal location - self._create_town() - - def _create_town(self): - """Create a simple town with a dungeon entrance.""" - # Create town zone - town_zone = Zone("town", "A peaceful town") - town_zone.level = 1 - town_zone.center = Coord(0, 0, 0) - town_zone.races = ["human"] - town_zone.items = ["torch", "Sword"] - - # Also set these on the dungeon zones - # (They need to be set for mob/item population) - if self.dungeon: - # Pre-configure the dungeon zone template - self.dungeon_zone_template = { - "races": ["bat", "wolf"], - "items": ["torch"] - } - - # Create town square location - town_square = Location( - "Town Square", - "A bustling town square with a fountain in the center. " - "To the north, you see an old stone archway leading down into darkness." - ) - town_square.world_location = Coord(0, 0, 0) - - # Add location to zone - town_zone.add_location(town_square) - self.add_zone(town_zone) - self.add_location(town_square, zone="town") - - # Create a dungeon entrance in the town square - dungeon_entrance = DungeonEntrance( - directions=["north", "down", "crypt"], - dungeon=self.dungeon, - short_descr="An ancient stone archway descends into darkness.", - long_descr="The archway is covered in moss and strange runes. " - "A cold wind blows from the depths below." - ) - - # Add the entrance to the location first - town_square.add_exits([dungeon_entrance]) - - # Then bind the entrance (this will generate the first dungeon level) - dungeon_entrance.bind(town_square) - def welcome(self, player: Player) -> str: """Welcome text when player enters a new game.""" player.tell("Welcome to the Town of Mysteries!", end=True) diff --git a/stories/dungeon_example/story_config.json b/stories/dungeon_example/story_config.json index f523fe05..34a71d22 100644 --- a/stories/dungeon_example/story_config.json +++ b/stories/dungeon_example/story_config.json @@ -15,8 +15,8 @@ "gametime_to_realtime": 1, "max_load_time": 60.0, "display_gametime": true, - "startlocation_player": "Town Square", - "startlocation_wizard": "Town Square", + "startlocation_player": "town.Town Square", + "startlocation_wizard": "town.Town Square", "zones": ["town"], "server_mode": "IF", "context": "A peaceful town with an ancient crypt beneath it.", diff --git a/stories/dungeon_example/world.json b/stories/dungeon_example/world.json index 6a9bc5c6..7f63e59e 100644 --- a/stories/dungeon_example/world.json +++ b/stories/dungeon_example/world.json @@ -2,7 +2,64 @@ "story": { "name": "Town of Mysteries" }, - "zones": {}, + "zones": { + "Town": { + "description": "town", + "level": 1, + "mood": 3, + "races": [], + "items": [], + "size": 5, + "center": [ + 0, + 0, + 0 + ], + "name": "town", + "locations": [ + { + "name": "Town Square", + "descr": "A bustling town square with a fountain in the center. ", + "short_descr": "", + "exits": [ + { + "name": "Ancient Crypt", + "direction": "north", + "short_descr": "An ancient stone archway descends into darkness.", + "long_descr": "The archway is covered in moss and strange runes. A cold wind blows from the depths below.", + "type": "DungeonEntrance", + "dungeon_name": "Ancient Crypt" + } + ], + "world_location": [ + 0, + 0, + 0 + ], + "built": false + }, + { + "name": "Ancient Crypt", + "exits": [ + { + "name": "Town Square", + "direction": "south", + "short_descr": "A stone archway leading back to the town square.", + "long_descr": "The archway is covered in moss and strange runes. Sunlight filters down from above." + } + ] + , + "world_location": [ + 0, + 0, + 1 + ], + "built": false + + } + ] + } + }, "world": { "npcs": {}, "items": {}, diff --git a/tale/driver.py b/tale/driver.py index 0ac4e6fc..3477e17c 100644 --- a/tale/driver.py +++ b/tale/driver.py @@ -623,6 +623,7 @@ def go_through_exit(self, player: player.Player, direction: str, evoke: bool=Tru xt = player.location.exits[direction] xt.allow_passage(player) target_location = xt.target # type: base.Location + if not target_location.built: dynamic_story = typing.cast(DynamicStory, self.story) zone = dynamic_story.find_zone(location=player.location.name) diff --git a/tale/dungeon/dungeon.py b/tale/dungeon/dungeon.py index 92e3c108..5f158ba3 100644 --- a/tale/dungeon/dungeon.py +++ b/tale/dungeon/dungeon.py @@ -6,7 +6,7 @@ """ import random -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Sequence, Tuple, Union from tale.base import Door, Exit, Location from tale.coord import Coord @@ -148,7 +148,7 @@ def _describe_rooms(self, zone: Zone, layout: Layout, rooms: list): name=unique_name, descr=room_data.get("description", "A dungeon room.") ) - location.world_location = list(layout.cells.values())[i].coord + #location.world_location = list(layout.cells.values())[i].coord zone.add_location(location=location) self.story.add_location(zone=zone.name, location=location) return @@ -181,7 +181,7 @@ def _describe_rooms(self, zone: Zone, layout: Layout, rooms: list): i += 1 location = Location(name=room_name, descr=room.description) - location.world_location = list(layout.cells.values())[room.index].coord + # location.world_location = list(layout.cells.values())[room.index].coord zone.add_location(location=location) self.story.add_location(zone=zone.name, location=location) @@ -249,36 +249,24 @@ class DungeonEntrance(Exit): This can be added to any normal location to provide access to a dungeon. """ - - def __init__(self, directions: list, dungeon: Dungeon, - short_descr: str = "A dark entrance leads into a dungeon.", - long_descr: str = ""): - """ - Create a dungeon entrance. - - Args: - directions: Direction names to use for this exit - dungeon: The dungeon this entrance leads to - short_descr: Short description of the entrance - long_descr: Long description of the entrance + + def build_dungeon(self, story: 'DynamicStory', llm_util) -> None: """ - self.dungeon = dungeon - # Use a placeholder string target that will be resolved later - super().__init__(directions, "__dungeon_placeholder__", short_descr, long_descr) - self._dungeon_bound = False - - def bind(self, from_location: Location): + Build the dungeon if not already built. """ - Bind the entrance to a location and generate the first dungeon level. - - Args: - from_location: The location this entrance is in - """ - if self._dungeon_bound: - return - + # Create the first zone for the dungeon - zone = Zone(f"{self.dungeon.name}_level_0", f"Level 0 of {self.dungeon.name}") + self.dungeon = Dungeon( + name=self.short_description, + story=story, + llm_util=llm_util, + layout_generator=LayoutGenerator(), + mob_populator=MobPopulator(), + item_populator=ItemPopulator(), + max_depth=3 + ) + # Create the first zone for the dungeon + zone = Zone(f"{self.name}_level_0", f"Level 0 of {self.name}") zone.level = 1 zone.center = Coord(0, 0, 0) # Set default creatures and items for the dungeon @@ -292,10 +280,4 @@ def bind(self, from_location: Location): self.dungeon.generate_level(zone, depth=0) # Get the entrance location and update the target - entrance_loc = self.dungeon.get_entrance_location() - if entrance_loc: - self.target = entrance_loc - self._dungeon_bound = True - - # Call the parent bind method to actually add the exit to the location - super().bind(from_location) + self.target = self.dungeon.get_entrance_location() diff --git a/tale/json_story.py b/tale/json_story.py index cffb02c3..b64e16c2 100644 --- a/tale/json_story.py +++ b/tale/json_story.py @@ -1,6 +1,7 @@ from tale import load_items, wearable from tale.items import generic from tale.llm.dynamic_story import DynamicStory +from tale.parse import parse_locations from tale.player import Player from tale.story import StoryConfig import tale.parse_utils as parse_utils @@ -20,7 +21,7 @@ def init(self, driver) -> None: zones = [] world = parse_utils.load_json(self.path +'world.json') for zone in world['zones'].values(): - zones, exits = parse_utils.load_locations(zone) + zones, exits = parse_locations.load_locations(zone) if len(zones) < 1: print("No zones found in story config") return diff --git a/tale/parse/parse_locations.py b/tale/parse/parse_locations.py new file mode 100644 index 00000000..aa567656 --- /dev/null +++ b/tale/parse/parse_locations.py @@ -0,0 +1,82 @@ + +from typing import Tuple +from tale import zone +from tale.base import Exit +from tale.coord import Coord +from tale.dungeon.dungeon import DungeonEntrance +from tale.load_items import load_item +from tale.parse_utils import location_from_json, opposite_direction + + +def load_locations(json_file: dict) -> Tuple[dict, list]: + """ + Loads locations from supplied json file and generates exits connecting them + Returns dict of locations, list of exits + """ + locations = {} + exits = [] + temp_exits = {} + parsed_exits = [] + zones = {} + zone1 = zone.from_json(json_file) + zones[json_file['name']] = zone1 + for loc in json_file['locations']: + name = loc['name'] + location = location_from_json(loc) + if loc.get('world_location', None): + location.world_location = Coord(loc['world_location'][0], loc['world_location'][1], loc['world_location'][2]) + locations[name] = location + zone1.add_location(location) + loc_exits = loc['exits'] + for loc_exit in loc_exits: + temp_exits.setdefault(name,{})[loc_exit['name']] = loc_exit + + if loc.get('items', None): + for item in loc['items']: + loaded_item = load_item(item) + location.insert(loaded_item, None) + + for from_name, exits_dict in temp_exits.items(): + from_loc = locations[from_name] + for to_name, exit_to in exits_dict.items(): + exit_from = temp_exits[to_name][from_name] + if [exit_from, exit_to] in parsed_exits or [exit_to, exit_from] in parsed_exits: + continue + to_loc = locations[to_name] + + directions = [to_name] + + return_directions = [from_name] + direction = exit_to.get('direction', '') + return_direction = exit_from.get('direction', '') + # doing opposite since exit_to has direction + if direction or return_direction: + directions.append(direction or opposite_direction(return_direction)) + return_directions.append(opposite_direction(direction) or return_direction) + + if exit_to.get('type', '') == 'DungeonEntrance': + exit1 = DungeonEntrance(directions=directions, + target_location=to_loc, + short_descr=exit_to['short_descr'], + long_descr=exit_to['long_descr'], + enter_msg=exit_to.get('enter_msg', 'You enter the dungeon.')) + exits.append(exit1) + exit1.bind(from_loc) + exit2 = Exit(directions=return_directions, + target_location=from_loc, + short_descr=exit_from['short_descr'], + long_descr=exit_from['long_descr']) + exits.append(exit2) + exit2.bind(to_loc) + + else: + exits.append(Exit.connect(from_loc=from_loc, + directions=directions, + short_descr=exit_to['short_descr'], + long_descr=exit_to['long_descr'], + to_loc=to_loc, + return_short_descr=exit_from['short_descr'], + return_long_descr=exit_from['long_descr'], + return_directions=return_directions)) + parsed_exits.append([exit_from, exit_to]) + return zones, exits \ No newline at end of file diff --git a/tale/parse_utils.py b/tale/parse_utils.py index 8989acdf..a596679a 100644 --- a/tale/parse_utils.py +++ b/tale/parse_utils.py @@ -1,9 +1,8 @@ import random -from typing import List, Tuple +from typing import List from tale import lang -from tale import zone from tale import wearable -from tale.base import Living, Location, Exit, Item, Stats, Weapon, Wearable +from tale.base import Living, Location, Exit, Item, Stats, Weapon from tale.coord import Coord from tale.equip_npcs import equip_npc from tale.item_spawner import ItemSpawner @@ -34,62 +33,6 @@ def load_json(file_path: str): with open(file_path) as f: return json.load(f, strict=False) -def load_locations(json_file: dict) -> Tuple[dict, list]: - """ - Loads locations from supplied json file and generates exits connecting them - Returns dict of locations, list of exits - """ - locations = {} - exits = [] - temp_exits = {} - parsed_exits = [] - zones = {} - zone1 = zone.from_json(json_file) - zones[json_file['name']] = zone1 - for loc in json_file['locations']: - name = loc['name'] - location = location_from_json(loc) - if loc.get('world_location', None): - location.world_location = Coord(loc['world_location'][0], loc['world_location'][1], loc['world_location'][2]) - locations[name] = location - zone1.add_location(location) - loc_exits = loc['exits'] - for loc_exit in loc_exits: - temp_exits.setdefault(name,{})[loc_exit['name']] = loc_exit - - if loc.get('items', None): - for item in loc['items']: - loaded_item = load_item(item) - location.insert(loaded_item, None) - - for from_name, exits_dict in temp_exits.items(): - from_loc = locations[from_name] - for to_name, exit_to in exits_dict.items(): - exit_from = temp_exits[to_name][from_name] - if [exit_from, exit_to] in parsed_exits or [exit_to, exit_from] in parsed_exits: - continue - to_loc = locations[to_name] - - directions = [to_name] - - return_directions = [from_name] - direction = exit_to.get('direction', '') - return_direction = exit_from.get('direction', '') - # doing opposite since exit_to has direction - if direction or return_direction: - directions.append(direction or opposite_direction(return_direction)) - return_directions.append(opposite_direction(direction) or return_direction) - - exits.append(Exit.connect(from_loc=from_loc, - directions=directions, - short_descr=exit_to['short_descr'], - long_descr=exit_to['long_descr'], - to_loc=to_loc, - return_short_descr=exit_from['short_descr'], - return_long_descr=exit_from['long_descr'], - return_directions=return_directions)) - parsed_exits.append([exit_from, exit_to]) - return zones, exits def location_from_json(json_object: dict): location = Location(name=json_object['name'], descr=json_object.get('descr', '')) diff --git a/tests/files/test_dungeon_entrance.json b/tests/files/test_dungeon_entrance.json new file mode 100644 index 00000000..36d45367 --- /dev/null +++ b/tests/files/test_dungeon_entrance.json @@ -0,0 +1,18 @@ +{ + "name":"test dungeon entrance", + "description":"test house description", + "races":["Kobbo"], + "items":[], + "locations":[ + { + "name": "test room", + "descr": "test room description", + "exits": [{"name" : "dungeon", "short_descr":"dungeon entrance", "long_descr":"long description exit to dungeon", "enter_msg":"you pass through the dungeon entrance and enter the dungeon", "type":"DungeonEntrance"}] + }, + { + "name": "dungeon", + "descr": "dungeon description", + "exits": [{"name" : "test room", "direction":"north", "short_descr":"exit to test room", "long_descr":"", "enter_msg":"you exit the dungeon"}] + } + ] +} diff --git a/tests/parse/__init__.py b/tests/parse/__init__.py new file mode 100644 index 00000000..5923d63c --- /dev/null +++ b/tests/parse/__init__.py @@ -0,0 +1,7 @@ +""" +Unit test suite. + +'Tale' mud driver, mudlib and interactive fiction framework +Copyright by Irmen de Jong (irmen@razorvine.net) +""" + diff --git a/tests/parse/test_parse_locations.py b/tests/parse/test_parse_locations.py new file mode 100644 index 00000000..cb3ed535 --- /dev/null +++ b/tests/parse/test_parse_locations.py @@ -0,0 +1,34 @@ + + +from tale import parse_utils +from tale.dungeon.dungeon import DungeonEntrance +from tale.parse import parse_locations + + +class TestParseLocations(): + + def test_load_locations(self): + room_json = parse_utils.load_json("tests/files/test_locations.json") + zones, exits = parse_locations.load_locations(room_json) + room_one = zones['test house'].get_location('test room') + assert(room_one.name == 'test room') + assert(room_one.description == 'test room description') + room_two = zones['test house'].get_location('test room 2') + assert(room_two.name == 'test room 2') + assert(room_two.description == 'test room 2 description') + assert(len(room_two.exits) == 2) + assert(room_two.exits['north'].target == room_one) + assert(room_two.exits['test room'].target == room_one) + + assert(exits[0].__repr__().startswith("( 0 print(f"Story loaded successfully with {len(town_zone.locations)} town location(s)") - print(f"Dungeon has {len(dungeon_zone.locations)} location(s) in level 0") diff --git a/tests/test_parse_utils.py b/tests/test_parse_utils.py index 41ff712b..746c58c7 100644 --- a/tests/test_parse_utils.py +++ b/tests/test_parse_utils.py @@ -24,21 +24,6 @@ class TestParseUtils(): def test_load_json(self): assert(parse_utils.load_json("tests/files/test.json")) - def test_load_locations(self): - room_json = parse_utils.load_json("tests/files/test_locations.json") - zones, exits = parse_utils.load_locations(room_json) - room_one = zones['test house'].get_location('test room') - assert(room_one.name == 'test room') - assert(room_one.description == 'test room description') - room_two = zones['test house'].get_location('test room 2') - assert(room_two.name == 'test room 2') - assert(room_two.description == 'test room 2 description') - assert(len(room_two.exits) == 2) - assert(room_two.exits['north'].target == room_one) - assert(room_two.exits['test room'].target == room_one) - - assert(exits[0].__repr__().startswith("( Date: Sat, 15 Nov 2025 20:43:37 +0100 Subject: [PATCH 05/15] dungeons use internal grid --- tale/dungeon/dungeon.py | 15 +++++++++------ tale/llm/dynamic_story.py | 5 +++-- tale/parse/parse_locations.py | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/tale/dungeon/dungeon.py b/tale/dungeon/dungeon.py index 5f158ba3..b69037fa 100644 --- a/tale/dungeon/dungeon.py +++ b/tale/dungeon/dungeon.py @@ -55,6 +55,7 @@ def __init__(self, self.max_depth = max_depth self.current_depth = 0 self.zones = [] # type: list[Zone] + self._grid = dict() # type: dict[Coord, Location] def generate_level(self, zone: Zone, depth: int = 0) -> bool: """ @@ -148,9 +149,10 @@ def _describe_rooms(self, zone: Zone, layout: Layout, rooms: list): name=unique_name, descr=room_data.get("description", "A dungeon room.") ) - #location.world_location = list(layout.cells.values())[i].coord + location.world_location = list(layout.cells.values())[i].coord zone.add_location(location=location) - self.story.add_location(zone=zone.name, location=location) + self.story.add_location(zone=zone.name, location=location, add_to_grid=False) + self._grid[location.world_location.as_tuple()] = location return # Process rooms in batches of 10 @@ -181,9 +183,10 @@ def _describe_rooms(self, zone: Zone, layout: Layout, rooms: list): i += 1 location = Location(name=room_name, descr=room.description) - # location.world_location = list(layout.cells.values())[room.index].coord + location.world_location = list(layout.cells.values())[room.index].coord zone.add_location(location=location) - self.story.add_location(zone=zone.name, location=location) + self.story.add_location(zone=zone.name, location=location, add_to_grid=False) + self._grid[location.world_location.as_tuple()] = location return described_rooms @@ -191,8 +194,8 @@ def _connect_locations(self, layout: Layout) -> None: """Connect locations based on the layout.""" connections = layout.connections for connection in connections: - cell_location = self.story.world._grid.get(connection.coord.as_tuple(), None) - parent_location = self.story.world._grid.get(connection.other.as_tuple(), None) + cell_location = self._grid.get(connection.coord.as_tuple(), None) + parent_location = self._grid.get(connection.other.as_tuple(), None) if not cell_location or not parent_location: continue diff --git a/tale/llm/dynamic_story.py b/tale/llm/dynamic_story.py index bb5863a5..601c55dc 100644 --- a/tale/llm/dynamic_story.py +++ b/tale/llm/dynamic_story.py @@ -71,13 +71,14 @@ def find_zone(self, location: str) -> Zone: return zone return None - def add_location(self, location: Location, zone: str = '') -> bool: + def add_location(self, location: Location, zone: str = '', add_to_grid: bool = True) -> bool: """ Add a location to the story. If zone is specified, add to that zone, otherwise add to first zone. """ self._world._locations[location.name] = location coord = location.world_location - self._world._grid[coord.as_tuple()] = location + if add_to_grid: + self._world._grid[coord.as_tuple()] = location if zone: return self._zones[zone].add_location(location) for zone in self._zones: diff --git a/tale/parse/parse_locations.py b/tale/parse/parse_locations.py index aa567656..d1c4ab7b 100644 --- a/tale/parse/parse_locations.py +++ b/tale/parse/parse_locations.py @@ -59,7 +59,7 @@ def load_locations(json_file: dict) -> Tuple[dict, list]: target_location=to_loc, short_descr=exit_to['short_descr'], long_descr=exit_to['long_descr'], - enter_msg=exit_to.get('enter_msg', 'You enter the dungeon.')) + enter_msg=exit_to.get('enter_msg', 'You enter a dungeon.')) exits.append(exit1) exit1.bind(from_loc) exit2 = Exit(directions=return_directions, From 4678be9f7137ff7beb9d0d81e8b0643ce3b0418d Mon Sep 17 00:00:00 2001 From: rickard Date: Sat, 15 Nov 2025 21:45:58 +0100 Subject: [PATCH 06/15] move dungeonentrance to own file --- stories/dungeon_example/story.py | 3 +- tale/driver.py | 6 ++++ tale/dungeon/DungeonEntrance.py | 48 +++++++++++++++++++++++++++++ tale/dungeon/dungeon.py | 40 +----------------------- tale/parse/parse_locations.py | 2 +- tests/parse/test_parse_locations.py | 2 +- tests/test_dungeon.py | 23 +++++--------- tests/test_dungeon_example_story.py | 2 +- 8 files changed, 68 insertions(+), 58 deletions(-) create mode 100644 tale/dungeon/DungeonEntrance.py diff --git a/stories/dungeon_example/story.py b/stories/dungeon_example/story.py index 4ff4933a..801084d2 100644 --- a/stories/dungeon_example/story.py +++ b/stories/dungeon_example/story.py @@ -12,7 +12,8 @@ from tale.base import Location from tale.coord import Coord from tale.driver import Driver -from tale.dungeon.dungeon import Dungeon, DungeonEntrance +from tale.dungeon.DungeonEntrance import DungeonEntrance +from tale.dungeon.dungeon import Dungeon from tale.dungeon.dungeon_generator import ItemPopulator, LayoutGenerator, MobPopulator from tale.json_story import JsonStory from tale.main import run_from_cmdline diff --git a/tale/driver.py b/tale/driver.py index 3477e17c..c752a9fb 100644 --- a/tale/driver.py +++ b/tale/driver.py @@ -23,6 +23,7 @@ from typing import Sequence, Union, Tuple, Any, Dict, Callable, Iterable, Generator, Set, List, MutableSequence, Optional import appdirs +from tale.dungeon import DungeonEntrance from tale.items.basic import Note from tale.llm import llm_config @@ -624,6 +625,11 @@ def go_through_exit(self, player: player.Player, direction: str, evoke: bool=Tru xt.allow_passage(player) target_location = xt.target # type: base.Location + if isinstance(xt, DungeonEntrance.DungeonEntrance): + dungeon_entrance = typing.cast(DungeonEntrance.DungeonEntrance, xt) + if not dungeon_entrance.dungeon: + dungeon_entrance.build_dungeon(self.story, self.llm_util) + if not target_location.built: dynamic_story = typing.cast(DynamicStory, self.story) zone = dynamic_story.find_zone(location=player.location.name) diff --git a/tale/dungeon/DungeonEntrance.py b/tale/dungeon/DungeonEntrance.py new file mode 100644 index 00000000..6067d0d2 --- /dev/null +++ b/tale/dungeon/DungeonEntrance.py @@ -0,0 +1,48 @@ +from tale.base import Exit +from tale.coord import Coord +from tale.dungeon.dungeon import Dungeon +from tale.dungeon.dungeon_generator import ItemPopulator, LayoutGenerator, MobPopulator +from tale.llm.dynamic_story import DynamicStory +from tale.zone import Zone + + +class DungeonEntrance(Exit): + """ + A special exit that leads to a dungeon. + + This can be added to any normal location to provide access to a dungeon. + """ + + def build_dungeon(self, story: 'DynamicStory', llm_util) -> Dungeon: + """ + Build the dungeon if not already built. + """ + + # Create the first zone for the dungeon + self.dungeon = Dungeon( + name=self.short_description, + story=story, + llm_util=llm_util, + layout_generator=LayoutGenerator(), + mob_populator=MobPopulator(), + item_populator=ItemPopulator(), + max_depth=3 + ) + # Create the first zone for the dungeon + zone = Zone(f"{self.name}_level_0", f"Level 0 of {self.name}") + zone.level = 1 + zone.center = Coord(0, 0, 0) + # Set default creatures and items for the dungeon + zone.races = ["bat", "wolf"] + zone.items = ["torch"] + + # Add zone to story + self.dungeon.story.add_zone(zone) + + # Generate the first level + self.dungeon.generate_level(zone, depth=0) + + # Get the entrance location and update the target + self.target = self.dungeon.get_entrance_location() + + return self.dungeon \ No newline at end of file diff --git a/tale/dungeon/dungeon.py b/tale/dungeon/dungeon.py index b69037fa..5fbe173b 100644 --- a/tale/dungeon/dungeon.py +++ b/tale/dungeon/dungeon.py @@ -6,7 +6,7 @@ """ import random -from typing import TYPE_CHECKING, Sequence, Tuple, Union +from typing import TYPE_CHECKING from tale.base import Door, Exit, Location from tale.coord import Coord @@ -246,41 +246,3 @@ def get_entrance_location(self) -> Location: return list(self.zones[0].locations.values())[0] -class DungeonEntrance(Exit): - """ - A special exit that leads to a dungeon. - - This can be added to any normal location to provide access to a dungeon. - """ - - def build_dungeon(self, story: 'DynamicStory', llm_util) -> None: - """ - Build the dungeon if not already built. - """ - - # Create the first zone for the dungeon - self.dungeon = Dungeon( - name=self.short_description, - story=story, - llm_util=llm_util, - layout_generator=LayoutGenerator(), - mob_populator=MobPopulator(), - item_populator=ItemPopulator(), - max_depth=3 - ) - # Create the first zone for the dungeon - zone = Zone(f"{self.name}_level_0", f"Level 0 of {self.name}") - zone.level = 1 - zone.center = Coord(0, 0, 0) - # Set default creatures and items for the dungeon - zone.races = ["bat", "wolf"] - zone.items = ["torch"] - - # Add zone to story - self.dungeon.story.add_zone(zone) - - # Generate the first level - self.dungeon.generate_level(zone, depth=0) - - # Get the entrance location and update the target - self.target = self.dungeon.get_entrance_location() diff --git a/tale/parse/parse_locations.py b/tale/parse/parse_locations.py index d1c4ab7b..b2bafa16 100644 --- a/tale/parse/parse_locations.py +++ b/tale/parse/parse_locations.py @@ -3,7 +3,7 @@ from tale import zone from tale.base import Exit from tale.coord import Coord -from tale.dungeon.dungeon import DungeonEntrance +from tale.dungeon.DungeonEntrance import DungeonEntrance from tale.load_items import load_item from tale.parse_utils import location_from_json, opposite_direction diff --git a/tests/parse/test_parse_locations.py b/tests/parse/test_parse_locations.py index cb3ed535..189245af 100644 --- a/tests/parse/test_parse_locations.py +++ b/tests/parse/test_parse_locations.py @@ -1,7 +1,7 @@ from tale import parse_utils -from tale.dungeon.dungeon import DungeonEntrance +from tale.dungeon.DungeonEntrance import DungeonEntrance from tale.parse import parse_locations diff --git a/tests/test_dungeon.py b/tests/test_dungeon.py index a70e2a9a..65b71e33 100644 --- a/tests/test_dungeon.py +++ b/tests/test_dungeon.py @@ -9,7 +9,8 @@ from tale.base import Location from tale.coord import Coord from tale.driver_if import IFDriver -from tale.dungeon.dungeon import Dungeon, DungeonEntrance +from tale.dungeon.DungeonEntrance import DungeonEntrance +from tale.dungeon.dungeon import Dungeon from tale.dungeon.dungeon_generator import Cell, Connection, Layout, LayoutGenerator, MobPopulator, ItemPopulator from tale.json_story import JsonStory from tale.llm.llm_utils import LlmUtil @@ -124,14 +125,6 @@ def test_dungeon_entrance(self): mock_layout_generator = MagicMock() mock_layout_generator.generate.return_value = self.get_mock_layout() - dungeon = Dungeon( - name="Test Dungeon", - story=self.story, - layout_generator=mock_layout_generator, - mob_populator=MobPopulator(), - item_populator=ItemPopulator(), - max_depth=3 - ) # Create a location to add the entrance to location = Location("Test Location", "A test location") @@ -139,18 +132,18 @@ def test_dungeon_entrance(self): # Create the entrance entrance = DungeonEntrance( - directions=["down"], - dungeon=dungeon, - short_descr="A dark entrance" + directions=["test dungeon", "down"], + short_descr="A dark entrance", + target_location=location, ) - - # Bind the entrance entrance.bind(location) + + dungeon = entrance.build_dungeon(self.story, self.llm_util) # Verify the dungeon was created assert entrance.target is not None assert len(dungeon.zones) == 1 - assert dungeon.zones[0].name == "Test Dungeon_level_0" + assert dungeon.zones[0].name == "test dungeon_level_0" def test_dungeon_get_entrance_location(self): """Test getting the entrance location of a dungeon.""" diff --git a/tests/test_dungeon_example_story.py b/tests/test_dungeon_example_story.py index 292d2537..5e185901 100644 --- a/tests/test_dungeon_example_story.py +++ b/tests/test_dungeon_example_story.py @@ -8,7 +8,7 @@ from tale import parse_utils, util from tale.coord import Coord from tale.driver_if import IFDriver -from tale.dungeon.dungeon import DungeonEntrance +from tale.dungeon.DungeonEntrance import DungeonEntrance from tale.dungeon.dungeon_generator import LayoutGenerator, MobPopulator, ItemPopulator from tale.llm.llm_utils import LlmUtil from tests.supportstuff import FakeIoUtil From a3b16c3dc2bad1f3a79fb02e04e89133d3746601 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 16 Nov 2025 06:55:38 +0000 Subject: [PATCH 07/15] Add DungeonConfig class and integrate with Zone Co-authored-by: neph1 <7988802+neph1@users.noreply.github.com> --- stories/dungeon_example/world.json | 13 +++ tale/driver.py | 6 +- tale/dungeon/DungeonEntrance.py | 48 ++++++--- tale/dungeon/dungeon_config.py | 60 ++++++++++++ tale/zone.py | 17 +++- tests/test_dungeon.py | 43 +++++++- tests/test_dungeon_config.py | 152 +++++++++++++++++++++++++++++ 7 files changed, 322 insertions(+), 17 deletions(-) create mode 100644 tale/dungeon/dungeon_config.py create mode 100644 tests/test_dungeon_config.py diff --git a/stories/dungeon_example/world.json b/stories/dungeon_example/world.json index 7f63e59e..bd652b52 100644 --- a/stories/dungeon_example/world.json +++ b/stories/dungeon_example/world.json @@ -16,6 +16,13 @@ 0 ], "name": "town", + "dungeon_config": { + "name": "Ancient Crypt", + "description": "A dark and ancient crypt beneath the town", + "races": ["bat", "wolf", "skeleton"], + "items": ["torch", "Sword"], + "max_depth": 5 + }, "locations": [ { "name": "Town Square", @@ -93,6 +100,12 @@ "level": 2, "aggressive": true, "description": "A grey wolf" + }, + { + "name": "skeleton", + "level": 3, + "aggressive": true, + "description": "An animated skeleton warrior" } ] } diff --git a/tale/driver.py b/tale/driver.py index c752a9fb..45e8922e 100644 --- a/tale/driver.py +++ b/tale/driver.py @@ -628,7 +628,11 @@ def go_through_exit(self, player: player.Player, direction: str, evoke: bool=Tru if isinstance(xt, DungeonEntrance.DungeonEntrance): dungeon_entrance = typing.cast(DungeonEntrance.DungeonEntrance, xt) if not dungeon_entrance.dungeon: - dungeon_entrance.build_dungeon(self.story, self.llm_util) + # Get the dungeon config from the zone containing the entrance + dynamic_story = typing.cast(DynamicStory, self.story) + zone = dynamic_story.find_zone(location=player.location.name) + dungeon_config = zone.dungeon_config if zone else None + dungeon_entrance.build_dungeon(self.story, self.llm_util, dungeon_config) if not target_location.built: dynamic_story = typing.cast(DynamicStory, self.story) diff --git a/tale/dungeon/DungeonEntrance.py b/tale/dungeon/DungeonEntrance.py index 6067d0d2..d9db18c4 100644 --- a/tale/dungeon/DungeonEntrance.py +++ b/tale/dungeon/DungeonEntrance.py @@ -1,6 +1,7 @@ from tale.base import Exit from tale.coord import Coord from tale.dungeon.dungeon import Dungeon +from tale.dungeon.dungeon_config import DungeonConfig from tale.dungeon.dungeon_generator import ItemPopulator, LayoutGenerator, MobPopulator from tale.llm.dynamic_story import DynamicStory from tale.zone import Zone @@ -13,28 +14,47 @@ class DungeonEntrance(Exit): This can be added to any normal location to provide access to a dungeon. """ - def build_dungeon(self, story: 'DynamicStory', llm_util) -> Dungeon: + def build_dungeon(self, story: 'DynamicStory', llm_util, config: DungeonConfig = None) -> Dungeon: """ Build the dungeon if not already built. + + Args: + story: The story this dungeon belongs to + llm_util: LLM utility for generating descriptions + config: DungeonConfig defining the dungeon properties (optional) """ + # Use provided config or create default + if config is None: + config = DungeonConfig( + name=self.short_description if hasattr(self, 'short_description') else "Dungeon", + description="A dark dungeon", + races=["bat", "wolf"], + items=["torch"], + max_depth=3 + ) - # Create the first zone for the dungeon + # Create the dungeon self.dungeon = Dungeon( - name=self.short_description, - story=story, - llm_util=llm_util, - layout_generator=LayoutGenerator(), - mob_populator=MobPopulator(), - item_populator=ItemPopulator(), - max_depth=3 + name=config.name, + story=story, + llm_util=llm_util, + layout_generator=LayoutGenerator(), + mob_populator=MobPopulator(), + item_populator=ItemPopulator(), + max_depth=config.max_depth ) - # Create the first zone for the dungeon - zone = Zone(f"{self.name}_level_0", f"Level 0 of {self.name}") + + # Create the first zone for the dungeon + zone = Zone(f"{config.name}_level_0", f"Level 0 of {config.name}") zone.level = 1 zone.center = Coord(0, 0, 0) - # Set default creatures and items for the dungeon - zone.races = ["bat", "wolf"] - zone.items = ["torch"] + + # Set creatures and items from config + zone.races = config.races + zone.items = config.items + + # Store the dungeon config in the zone + zone.dungeon_config = config # Add zone to story self.dungeon.story.add_zone(zone) diff --git a/tale/dungeon/dungeon_config.py b/tale/dungeon/dungeon_config.py new file mode 100644 index 00000000..9594ed8c --- /dev/null +++ b/tale/dungeon/dungeon_config.py @@ -0,0 +1,60 @@ +""" +Configuration for dungeon generation. + +This configuration is stored in the Zone and defines how dungeons +should be generated for that zone. +""" + + +class DungeonConfig: + """ + Configuration for dungeon generation. + + This config defines properties for procedurally generated dungeons + attached to a zone. + """ + + def __init__(self, + name: str = "Dungeon", + description: str = "A dark dungeon", + races: list = None, + items: list = None, + max_depth: int = 3): + """ + Initialize a dungeon configuration. + + Args: + name: Name of the dungeon + description: Description of the dungeon + races: List of creature races that can spawn in the dungeon + items: List of items that can spawn in the dungeon + max_depth: Maximum number of levels in the dungeon + """ + self.name = name + self.description = description + self.races = races or ["bat", "wolf"] + self.items = items or ["torch"] + self.max_depth = max_depth + + def to_json(self) -> dict: + """Serialize the dungeon config to JSON.""" + return { + "name": self.name, + "description": self.description, + "races": self.races, + "items": self.items, + "max_depth": self.max_depth + } + + @staticmethod + def from_json(data: dict) -> 'DungeonConfig': + """Deserialize a dungeon config from JSON.""" + if not data: + return None + return DungeonConfig( + name=data.get("name", "Dungeon"), + description=data.get("description", "A dark dungeon"), + races=data.get("races", ["bat", "wolf"]), + items=data.get("items", ["torch"]), + max_depth=data.get("max_depth", 3) + ) diff --git a/tale/zone.py b/tale/zone.py index ecc6319d..443dda97 100644 --- a/tale/zone.py +++ b/tale/zone.py @@ -1,5 +1,9 @@ from tale.base import Location from tale.coord import Coord +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tale.dungeon.dungeon_config import DungeonConfig class Zone(): @@ -17,6 +21,7 @@ def __init__(self, name: str, description: str = '') -> None: self.center = Coord(0,0,0) self.name = name self.lore = "" + self.dungeon_config = None # type: DungeonConfig def add_location(self, location: Location) -> bool: """ Add a location to the zone. Skip if location already exists.""" @@ -36,7 +41,7 @@ def get_location(self, name: str) -> Location: return self.locations.get(name, None) def get_info(self) -> dict: - return {"description":self.description, + info = {"description":self.description, "level":self.level, "mood":self.mood, "races":self.races, @@ -45,6 +50,9 @@ def get_info(self) -> dict: "center":self.center.as_tuple(), "lore":self.lore, } + if self.dungeon_config: + info["dungeon_config"] = self.dungeon_config.to_json() + return info def get_neighbor(self, direction: str) -> 'Zone': return self.neighbors[direction] @@ -78,6 +86,8 @@ def on_edge(self, coord: Coord, direction: Coord) -> bool: return False def from_json(data: dict) -> 'Zone': + from tale.dungeon.dungeon_config import DungeonConfig + zone = Zone(data.get("name", "unknown"), data.get("description", "unknown")) zone.level = data.get("level", 1) zone.mood = data.get("mood", 0) @@ -88,4 +98,9 @@ def from_json(data: dict) -> 'Zone': center = data.get("center") zone.center = Coord(center[0], center[1], center[2]) zone.lore = data.get("lore", "") + + # Load dungeon config if present + if data.get("dungeon_config", None) is not None: + zone.dungeon_config = DungeonConfig.from_json(data["dungeon_config"]) + return zone diff --git a/tests/test_dungeon.py b/tests/test_dungeon.py index 65b71e33..eeb58b46 100644 --- a/tests/test_dungeon.py +++ b/tests/test_dungeon.py @@ -143,7 +143,8 @@ def test_dungeon_entrance(self): # Verify the dungeon was created assert entrance.target is not None assert len(dungeon.zones) == 1 - assert dungeon.zones[0].name == "test dungeon_level_0" + # The zone name is based on the dungeon name from config, which defaults to short_descr + assert dungeon.zones[0].name == "A dark entrance_level_0" def test_dungeon_get_entrance_location(self): """Test getting the entrance location of a dungeon.""" @@ -172,3 +173,43 @@ def test_dungeon_get_entrance_location(self): assert entrance_location is not None assert entrance_location.name in zone.locations + + def test_dungeon_entrance_with_config(self): + """Test creating a dungeon entrance with a custom config.""" + from tale.dungeon.dungeon_config import DungeonConfig + + mock_layout_generator = MagicMock() + mock_layout_generator.generate.return_value = self.get_mock_layout() + + # Create a location to add the entrance to + location = Location("Test Location", "A test location") + location.world_location = Coord(0, 0, 0) + + # Create a custom dungeon config + config = DungeonConfig( + name="Custom Dungeon", + description="A custom dungeon", + races=["dragon", "goblin"], + items=["sword", "shield"], + max_depth=7 + ) + + # Create the entrance + entrance = DungeonEntrance( + directions=["custom", "down"], + short_descr="A mystical entrance", + target_location=location, + ) + entrance.bind(location) + + dungeon = entrance.build_dungeon(self.story, self.llm_util, config) + + # Verify the dungeon was created with custom config + assert entrance.target is not None + assert len(dungeon.zones) == 1 + assert dungeon.zones[0].name == "Custom Dungeon_level_0" + assert dungeon.zones[0].races == ["dragon", "goblin"] + assert dungeon.zones[0].items == ["sword", "shield"] + assert dungeon.max_depth == 7 + assert dungeon.zones[0].dungeon_config is not None + assert dungeon.zones[0].dungeon_config.name == "Custom Dungeon" diff --git a/tests/test_dungeon_config.py b/tests/test_dungeon_config.py new file mode 100644 index 00000000..05dd5e02 --- /dev/null +++ b/tests/test_dungeon_config.py @@ -0,0 +1,152 @@ +""" +Tests for DungeonConfig and Zone integration. +""" + +import json +from tale.coord import Coord +from tale.dungeon.dungeon_config import DungeonConfig +from tale.zone import Zone, from_json + + +class TestDungeonConfig: + """Test the DungeonConfig class.""" + + def test_dungeon_config_creation(self): + """Test creating a dungeon config.""" + config = DungeonConfig( + name="Test Dungeon", + description="A test dungeon", + races=["bat", "wolf"], + items=["torch", "sword"], + max_depth=5 + ) + + assert config.name == "Test Dungeon" + assert config.description == "A test dungeon" + assert config.races == ["bat", "wolf"] + assert config.items == ["torch", "sword"] + assert config.max_depth == 5 + + def test_dungeon_config_to_json(self): + """Test serializing a dungeon config to JSON.""" + config = DungeonConfig( + name="Test Dungeon", + description="A test dungeon", + races=["bat", "wolf"], + items=["torch"], + max_depth=3 + ) + + json_data = config.to_json() + + assert json_data["name"] == "Test Dungeon" + assert json_data["description"] == "A test dungeon" + assert json_data["races"] == ["bat", "wolf"] + assert json_data["items"] == ["torch"] + assert json_data["max_depth"] == 3 + + def test_dungeon_config_from_json(self): + """Test deserializing a dungeon config from JSON.""" + json_data = { + "name": "Test Dungeon", + "description": "A test dungeon", + "races": ["bat", "wolf"], + "items": ["torch"], + "max_depth": 3 + } + + config = DungeonConfig.from_json(json_data) + + assert config.name == "Test Dungeon" + assert config.description == "A test dungeon" + assert config.races == ["bat", "wolf"] + assert config.items == ["torch"] + assert config.max_depth == 3 + + def test_zone_with_dungeon_config(self): + """Test a zone with a dungeon config.""" + zone = Zone("Test Zone", "A test zone") + zone.dungeon_config = DungeonConfig( + name="Test Dungeon", + description="A test dungeon", + races=["bat"], + items=["torch"], + max_depth=3 + ) + + # Verify the config is stored + assert zone.dungeon_config is not None + assert zone.dungeon_config.name == "Test Dungeon" + + def test_zone_serialization_with_dungeon_config(self): + """Test serializing a zone with dungeon config.""" + zone = Zone("Test Zone", "A test zone") + zone.center = Coord(0, 0, 0) + zone.dungeon_config = DungeonConfig( + name="Test Dungeon", + description="A test dungeon", + races=["bat"], + items=["torch"], + max_depth=3 + ) + + # Serialize to JSON + zone_data = zone.get_info() + + # Verify dungeon_config is in the serialized data + assert "dungeon_config" in zone_data + assert zone_data["dungeon_config"]["name"] == "Test Dungeon" + assert zone_data["dungeon_config"]["races"] == ["bat"] + assert zone_data["dungeon_config"]["max_depth"] == 3 + + def test_zone_deserialization_with_dungeon_config(self): + """Test deserializing a zone with dungeon config.""" + zone_data = { + "name": "Test Zone", + "description": "A test zone", + "level": 1, + "mood": 0, + "items": [], + "races": [], + "size": 5, + "center": [0, 0, 0], + "lore": "", + "dungeon_config": { + "name": "Test Dungeon", + "description": "A test dungeon", + "races": ["bat", "wolf"], + "items": ["torch"], + "max_depth": 5 + } + } + + # Deserialize from JSON + zone = from_json(zone_data) + + # Verify dungeon_config was loaded + assert zone.dungeon_config is not None + assert zone.dungeon_config.name == "Test Dungeon" + assert zone.dungeon_config.description == "A test dungeon" + assert zone.dungeon_config.races == ["bat", "wolf"] + assert zone.dungeon_config.items == ["torch"] + assert zone.dungeon_config.max_depth == 5 + + def test_zone_without_dungeon_config(self): + """Test a zone without dungeon config.""" + zone_data = { + "name": "Test Zone", + "description": "A test zone", + "level": 1, + "mood": 0, + "items": [], + "races": [], + "size": 5, + "center": [0, 0, 0], + "lore": "" + } + + # Deserialize from JSON + zone = from_json(zone_data) + + # Verify dungeon_config is None + assert zone.dungeon_config is None From 00b7268b1cb86490bd795a7a953ab5fa029be7f6 Mon Sep 17 00:00:00 2001 From: rickard Date: Sun, 16 Nov 2025 12:14:55 +0100 Subject: [PATCH 08/15] remove default config and fix some bugs --- tale/dungeon/DungeonEntrance.py | 21 +++++++--------- .../responses/LocationDescriptionResponse.py | 25 +++++++++++++++++-- tests/test_dungeon.py | 8 +++++- tests/test_dungeon_generator.py | 9 +++++++ tests/test_item_spawner.py | 6 ++--- 5 files changed, 50 insertions(+), 19 deletions(-) diff --git a/tale/dungeon/DungeonEntrance.py b/tale/dungeon/DungeonEntrance.py index d9db18c4..451494d3 100644 --- a/tale/dungeon/DungeonEntrance.py +++ b/tale/dungeon/DungeonEntrance.py @@ -1,4 +1,5 @@ -from tale.base import Exit +from typing import Sequence, Union +from tale.base import Exit, Location from tale.coord import Coord from tale.dungeon.dungeon import Dungeon from tale.dungeon.dungeon_config import DungeonConfig @@ -14,24 +15,20 @@ class DungeonEntrance(Exit): This can be added to any normal location to provide access to a dungeon. """ - def build_dungeon(self, story: 'DynamicStory', llm_util, config: DungeonConfig = None) -> Dungeon: + def __init__(self, directions: Union[str, Sequence[str]], target_location: Union[str, Location], + short_descr: str, long_descr: str="", *, enter_msg: str="") -> None: + super().__init__(directions, target_location, short_descr, long_descr, enter_msg=enter_msg) + self.dungeon: Dungeon = None + + def build_dungeon(self, story: 'DynamicStory', llm_util, config: DungeonConfig) -> Dungeon: """ Build the dungeon if not already built. Args: story: The story this dungeon belongs to llm_util: LLM utility for generating descriptions - config: DungeonConfig defining the dungeon properties (optional) + config: DungeonConfig defining the dungeon properties """ - # Use provided config or create default - if config is None: - config = DungeonConfig( - name=self.short_description if hasattr(self, 'short_description') else "Dungeon", - description="A dark dungeon", - races=["bat", "wolf"], - items=["torch"], - max_depth=3 - ) # Create the dungeon self.dungeon = Dungeon( diff --git a/tale/llm/responses/LocationDescriptionResponse.py b/tale/llm/responses/LocationDescriptionResponse.py index 797fa9dd..aef08d8b 100644 --- a/tale/llm/responses/LocationDescriptionResponse.py +++ b/tale/llm/responses/LocationDescriptionResponse.py @@ -7,8 +7,29 @@ def __init__(self, response: dict): response = self._sanitize_room_descriptions(response) assert isinstance(response, list), f"Expected a list of rooms, got {response}" self.location_descriptions = [] - for location in response: - self.location_descriptions.append(LocationDescription(index=location.get('index', 0), name=location.get('name', ''), description=location.get('description', ''))) + if not response: + pass + elif isinstance(response[0], dict): + for location in response: + self.location_descriptions.append( + LocationDescription( + index=location.get("index", 0), + name=location.get("name", ""), + description=location.get("description", ""), + ) + ) + elif isinstance(response[0], (list, tuple)): + for location in response: + idx = location[0] if len(location) > 0 else 0 + name = location[1] if len(location) > 1 else "" + desc = location[2] if len(location) > 2 else "" + self.location_descriptions.append(LocationDescription(index=idx, name=name, description=desc)) + else: + # single flat location like [0, 'Entrance', 'desc'] + idx = response[0] if len(response) > 0 else 0 + name = response[1] if len(response) > 1 else "" + desc = response[2] if len(response) > 2 else "" + self.location_descriptions.append(LocationDescription(index=idx, name=name, description=desc)) self.valid = len(self.location_descriptions) > 0 diff --git a/tests/test_dungeon.py b/tests/test_dungeon.py index eeb58b46..40b2ffe5 100644 --- a/tests/test_dungeon.py +++ b/tests/test_dungeon.py @@ -11,6 +11,7 @@ from tale.driver_if import IFDriver from tale.dungeon.DungeonEntrance import DungeonEntrance from tale.dungeon.dungeon import Dungeon +from tale.dungeon.dungeon_config import DungeonConfig from tale.dungeon.dungeon_generator import Cell, Connection, Layout, LayoutGenerator, MobPopulator, ItemPopulator from tale.json_story import JsonStory from tale.llm.llm_utils import LlmUtil @@ -138,7 +139,12 @@ def test_dungeon_entrance(self): ) entrance.bind(location) - dungeon = entrance.build_dungeon(self.story, self.llm_util) + dungeon_config = DungeonConfig( + name="A dark entrance", + description="A dark entrance to a dungeon.", + ) + + dungeon = entrance.build_dungeon(self.story, self.llm_util, dungeon_config) # Verify the dungeon was created assert entrance.target is not None diff --git a/tests/test_dungeon_generator.py b/tests/test_dungeon_generator.py index e8958ed0..6a00fff5 100644 --- a/tests/test_dungeon_generator.py +++ b/tests/test_dungeon_generator.py @@ -124,3 +124,12 @@ def test_populate_items(self): assert len(spawners) == populator.max_items assert spawners[0].items[0]['name'] in ['torch', 'Sword', ] + + def test_populate_items_only_one(self): + self.zone.level = 3 + populator = ItemPopulator() + self.zone.items = ['Sword'] + spawners = populator.populate(zone=self.zone, story=self.story) + + assert len(spawners) == populator.max_items + assert spawners[0].items[0]['name'] in ['Sword'] diff --git a/tests/test_item_spawner.py b/tests/test_item_spawner.py index 0d0a1c1d..3dbd747c 100644 --- a/tests/test_item_spawner.py +++ b/tests/test_item_spawner.py @@ -1,11 +1,9 @@ import datetime -import random -import unittest from unittest.mock import MagicMock from pytest import raises -from tale import _MudContext, util +from tale import util from tale.base import Container, Item, Location from tale.driver_if import IFDriver from tale.item_spawner import ItemSpawner @@ -17,7 +15,7 @@ class TestItemSpawner(): def setup_method(self): driver = IFDriver(screen_delay=99, gui=False, web=True, wizard_override=True) driver.game_clock = util.GameDateTime(datetime.datetime(year=2023, month=1, day=1), 1) - #_MudContext.driver = driver + self.items = {'item1': {'name':'item1'}, 'item2':{'name':'item2'}, 'item3': {'name':'item3'}} self.item_probabilities = [0.3, 0.5, 0.2] self.zone = Zone(name='test_zone') From bf90a633c75f3d9d19dbe8d68b1c0e4f0b503953 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 16 Nov 2025 17:05:45 +0000 Subject: [PATCH 09/15] Initial plan From 0f9e141a043fd89d7de10a3100951a6106627bca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 16 Nov 2025 17:13:31 +0000 Subject: [PATCH 10/15] Implement dungeon config generation with 10% chance in get_neighbor_or_generate_zone Co-authored-by: neph1 <7988802+neph1@users.noreply.github.com> --- .../CREATE_DUNGEON_CONFIG_PROMPT.txt | 1 + .../DUNGEON_CONFIG_TEMPLATE.txt | 1 + tale/llm/requests/generate_dungeon_config.py | 20 ++++++ tale/llm/world_building.py | 28 +++++++++ tests/test_generate_dungeon_config.py | 61 +++++++++++++++++++ 5 files changed, 111 insertions(+) create mode 100644 tale/llm/prompt_templates/CREATE_DUNGEON_CONFIG_PROMPT.txt create mode 100644 tale/llm/prompt_templates/DUNGEON_CONFIG_TEMPLATE.txt create mode 100644 tale/llm/requests/generate_dungeon_config.py create mode 100644 tests/test_generate_dungeon_config.py diff --git a/tale/llm/prompt_templates/CREATE_DUNGEON_CONFIG_PROMPT.txt b/tale/llm/prompt_templates/CREATE_DUNGEON_CONFIG_PROMPT.txt new file mode 100644 index 00000000..b05ad230 --- /dev/null +++ b/tale/llm/prompt_templates/CREATE_DUNGEON_CONFIG_PROMPT.txt @@ -0,0 +1 @@ +{context}\n[USER_START]Using the information supplied inside the tags and the zone information: {zone_info}, create a dungeon configuration that would fit the zone's theme and atmosphere. The dungeon should be appropriate for the zone's level and mood. Choose creatures from the zone's races and items from the zone's items list. Fill in this JSON template and do not write anything else: {dungeon_config_template}. Write the response in valid JSON. \ No newline at end of file diff --git a/tale/llm/prompt_templates/DUNGEON_CONFIG_TEMPLATE.txt b/tale/llm/prompt_templates/DUNGEON_CONFIG_TEMPLATE.txt new file mode 100644 index 00000000..f3139fd8 --- /dev/null +++ b/tale/llm/prompt_templates/DUNGEON_CONFIG_TEMPLATE.txt @@ -0,0 +1 @@ +{"name": "", "description": "50 words", "races": [], "items": [], "max_depth": (int)} \ No newline at end of file diff --git a/tale/llm/requests/generate_dungeon_config.py b/tale/llm/requests/generate_dungeon_config.py new file mode 100644 index 00000000..41bd3e2f --- /dev/null +++ b/tale/llm/requests/generate_dungeon_config.py @@ -0,0 +1,20 @@ + + +from tale.llm import llm_config +from tale.llm.requests.llm_request import LlmRequest + + +class GenerateDungeonConfig(LlmRequest): + """ + Generates a dungeon configuration for a zone. + """ + + def build_prompt(self, args: dict) -> str: + zone_info = args['zone_info'] + + prompt = llm_config.params['PRE_JSON_PROMPT'] + prompt += llm_config.params['CREATE_DUNGEON_CONFIG_PROMPT'].format( + context='{context}', + zone_info=zone_info, + dungeon_config_template=llm_config.params['DUNGEON_CONFIG_TEMPLATE']) + return prompt diff --git a/tale/llm/world_building.py b/tale/llm/world_building.py index 076328d5..6ef6e213 100644 --- a/tale/llm/world_building.py +++ b/tale/llm/world_building.py @@ -12,6 +12,7 @@ from tale.llm.contexts.WorldGenerationContext import WorldGenerationContext from tale.llm.dynamic_story import DynamicStory from tale.llm.llm_io import IoUtil +from tale.llm.requests.generate_dungeon_config import GenerateDungeonConfig from tale.llm.requests.generate_zone import GenerateZone from tale.llm.requests.start_location import StartLocation from tale.llm.responses.LocationDescriptionResponse import LocationDescriptionResponse @@ -120,6 +121,11 @@ def get_neighbor_or_generate_zone(self, current_zone: Zone, current_location: Lo direction.multiply(json_result.get('size', current_zone.size_z if direction.z != 0 else current_zone.size)))) if zone and story.add_zone(zone): zone.level = (zone.level + 1) if random.random() < 0.5 else zone.level + # Generate dungeon config at 10% chance + if random.random() < 0.1: + dungeon_config = self._generate_dungeon_config(zone, world_generation_context) + if dungeon_config: + zone.dungeon_config = dungeon_config return zone return current_zone @@ -142,6 +148,28 @@ def _generate_zone(self, location_desc: str, context: WorldGenerationContext, ex except json.JSONDecodeError as exc: print(exc) return None + + def _generate_dungeon_config(self, zone: Zone, context: WorldGenerationContext): + """Generate a dungeon config for the given zone.""" + from tale.dungeon.dungeon_config import DungeonConfig + + prompt = GenerateDungeonConfig().build_prompt({ + 'zone_info': zone.get_info(), + }) + + request_body = deepcopy(self.default_body) + if self.json_grammar_key: + request_body[self.json_grammar_key] = self.json_grammar + result = self.io_util.synchronous_request(request_body, prompt=prompt, context=context.to_prompt_string()) + try: + json_result = json.loads(json_util.sanitize_json(result)) + return DungeonConfig.from_json(json_result) + except json.JSONDecodeError as exc: + print(f'Error generating dungeon config: {exc}') + return None + except Exception as exc: + print(f'Error generating dungeon config: {exc}') + return None def validate_zone(self, json_result: dict, center: Coord) -> Zone: """Create the Zone object.""" diff --git a/tests/test_generate_dungeon_config.py b/tests/test_generate_dungeon_config.py new file mode 100644 index 00000000..0d3d2d19 --- /dev/null +++ b/tests/test_generate_dungeon_config.py @@ -0,0 +1,61 @@ +""" +Tests for dungeon config generation. + +Note: These are basic tests that verify the structure and interfaces. +Full integration tests require the LLM dependencies (aiohttp, etc). +""" + +import json +from tale.dungeon.dungeon_config import DungeonConfig + + +class TestDungeonConfigGeneration: + """Test dungeon config generation components.""" + + def test_dungeon_config_prompt_template_exists(self): + """Test that the prompt templates exist and can be loaded.""" + from tale.llm import llm_config + + # Verify the prompt templates are loaded + assert 'CREATE_DUNGEON_CONFIG_PROMPT' in llm_config.params + assert 'DUNGEON_CONFIG_TEMPLATE' in llm_config.params + + # Verify template structure + template = llm_config.params['DUNGEON_CONFIG_TEMPLATE'] + assert 'name' in template + assert 'description' in template + assert 'races' in template + assert 'items' in template + assert 'max_depth' in template + + def test_dungeon_config_template_format(self): + """Test that the dungeon config template has the correct structure.""" + from tale.llm import llm_config + + template = llm_config.params['DUNGEON_CONFIG_TEMPLATE'] + + # Parse the template as JSON to verify it's valid + try: + # The template uses (int) placeholders, so we replace them for parsing + test_template = template.replace('(int)', '1') + test_data = json.loads(test_template) + + # Verify expected keys exist + assert 'name' in test_data + assert 'description' in test_data + assert 'races' in test_data + assert 'items' in test_data + assert 'max_depth' in test_data + except json.JSONDecodeError: + assert False, "DUNGEON_CONFIG_TEMPLATE is not valid JSON structure" + + def test_dungeon_config_prompt_has_context_placeholder(self): + """Test that the prompt has context placeholder.""" + from tale.llm import llm_config + + prompt = llm_config.params['CREATE_DUNGEON_CONFIG_PROMPT'] + assert '{context}' in prompt + assert '{zone_info}' in prompt + assert '{dungeon_config_template}' in prompt + + From 35c07981a3148b0a388442367e13ec3dc24242c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 18:35:29 +0000 Subject: [PATCH 11/15] Initial plan From 289cdeeafd723c4a8b822ac8284f89f0e5b359f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 18:47:20 +0000 Subject: [PATCH 12/15] Add dungeon entrance generation with location building Co-authored-by: neph1 <7988802+neph1@users.noreply.github.com> --- tale/driver.py | 3 +- tale/llm/llm_utils.py | 44 ++++++- .../CREATE_DUNGEON_ENTRANCE_PROMPT.txt | 1 + .../DUNGEON_ENTRANCE_TEMPLATE.txt | 1 + tale/llm/world_building.py | 23 ++++ tale/zone.py | 2 + tests/test_llm_utils.py | 117 ++++++++++++++++++ 7 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 tale/llm/prompt_templates/CREATE_DUNGEON_ENTRANCE_PROMPT.txt create mode 100644 tale/llm/prompt_templates/DUNGEON_ENTRANCE_TEMPLATE.txt diff --git a/tale/driver.py b/tale/driver.py index e088763c..6ce126c5 100644 --- a/tale/driver.py +++ b/tale/driver.py @@ -934,7 +934,8 @@ def build_location(self, targetLocation: base.Location, zone: Zone, player: play zone_info=zone.get_info(), world_creatures=dynamic_story.catalogue._creatures, world_items=dynamic_story.catalogue._items, - neighbors=neighbor_locations) + neighbors=neighbor_locations, + zone=zone) new_locations = result.new_locations exits = result.exits npcs = result.npcs diff --git a/tale/llm/llm_utils.py b/tale/llm/llm_utils.py index 97340384..fcab6482 100644 --- a/tale/llm/llm_utils.py +++ b/tale/llm/llm_utils.py @@ -159,7 +159,7 @@ def generate_character(self, story_context: str = '', keywords: list = [], story def get_neighbor_or_generate_zone(self, current_zone: Zone, current_location: Location, target_location: Location) -> Zone: return self._world_building.get_neighbor_or_generate_zone(current_zone, current_location, target_location, self.__story) - def build_location(self, location: Location, exit_location_name: str, zone_info: dict, world_items: dict = {}, world_creatures: dict = {}, neighbors: dict = {}) -> Tuple[LocationResponse, MobSpawner]: + def build_location(self, location: Location, exit_location_name: str, zone_info: dict, world_items: dict = {}, world_creatures: dict = {}, neighbors: dict = {}, zone: Zone = None) -> Tuple[LocationResponse, MobSpawner]: """ Generate a location based on the current story context""" world_generation_context = WorldGenerationContext(story_context=self.__story_context, story_type=self.__story_type, @@ -173,6 +173,42 @@ def build_location(self, location: Location, exit_location_name: str, zone_info: world_items=world_items if world_items else self.__story.catalogue._items, neighbors=neighbors) + # Maybe generate a dungeon entrance if conditions are met + if zone and zone_info.get('dungeon_config') and not zone.dungeon and location_result.exits: + import random + if random.random() < 0.1: # 10% chance + # Pick a random exit to replace with dungeon entrance + exit_to_replace = random.choice(location_result.exits) + + # Generate dungeon entrance + dungeon_entrance_data = self.generate_dungeon_entrance(location, zone_info['dungeon_config']) + + if dungeon_entrance_data: + from tale.dungeon.DungeonEntrance import DungeonEntrance + from tale.dungeon.dungeon_config import DungeonConfig + + # Create DungeonEntrance object + dungeon_config = DungeonConfig.from_json(zone_info['dungeon_config']) + + # Use direction from generated data or fall back to exit's direction + direction = dungeon_entrance_data.get('direction', exit_to_replace.name) + + dungeon_entrance = DungeonEntrance( + directions=direction, + target_location=exit_to_replace.target, + short_descr=dungeon_entrance_data.get('short_descr', ''), + long_descr=dungeon_entrance_data.get('long_descr', ''), + enter_msg='' + ) + + # Build the dungeon for this entrance + dungeon = dungeon_entrance.build_dungeon(self.__story, self, dungeon_config) + zone.dungeon = dungeon + + # Replace the exit in the location_result + exit_index = location_result.exits.index(exit_to_replace) + location_result.exits[exit_index] = dungeon_entrance + if not location.avatar and self.__story.config.image_gen: self.generate_image(location.name, location.description) return location_result, spawner @@ -254,6 +290,12 @@ def generate_dungeon_locations(self, zone_info: dict, locations: list, depth: in rooms=locations, depth=depth, max_depth=max_depth)) + + def generate_dungeon_entrance(self, location: Location, dungeon_config: dict) -> dict: + """Generate a dungeon entrance that fits the location and dungeon config.""" + return self._world_building.generate_dungeon_entrance(location=location, + dungeon_config=dungeon_config, + context=self._get_world_context()) # visible for testing def generate_image(self, name: str, description: dict = '', save_path: str = "./resources", copy_file: bool = True, target: MudObject = None, id: str = None) -> bool: diff --git a/tale/llm/prompt_templates/CREATE_DUNGEON_ENTRANCE_PROMPT.txt b/tale/llm/prompt_templates/CREATE_DUNGEON_ENTRANCE_PROMPT.txt new file mode 100644 index 00000000..f9775187 --- /dev/null +++ b/tale/llm/prompt_templates/CREATE_DUNGEON_ENTRANCE_PROMPT.txt @@ -0,0 +1 @@ +{context} Location: {location_name}, {location_description}; Dungeon config: {dungeon_config};\n[USER_START]Using the information supplied inside the tags, create a dungeon entrance that fits both the current location and the dungeon configuration. The entrance should be an exit that leads into the dungeon. Consider the location's theme and the dungeon's atmosphere when describing the entrance. Fill in this JSON template and do not write anything else: {dungeon_entrance_template}. Write the response in valid JSON. \ No newline at end of file diff --git a/tale/llm/prompt_templates/DUNGEON_ENTRANCE_TEMPLATE.txt b/tale/llm/prompt_templates/DUNGEON_ENTRANCE_TEMPLATE.txt new file mode 100644 index 00000000..abd4e0be --- /dev/null +++ b/tale/llm/prompt_templates/DUNGEON_ENTRANCE_TEMPLATE.txt @@ -0,0 +1 @@ +{{"direction":"", "name":"name of dungeon entrance", "short_descr":"entrance description", "long_descr":"detailed entrance description"}} \ No newline at end of file diff --git a/tale/llm/world_building.py b/tale/llm/world_building.py index 6ef6e213..882b7572 100644 --- a/tale/llm/world_building.py +++ b/tale/llm/world_building.py @@ -372,6 +372,29 @@ def generate_dungeon_locations(self, context: DungeonLocationsContext) -> Locati except json.JSONDecodeError as exc: print(exc) return LocationDescriptionResponse([]) + + def generate_dungeon_entrance(self, location: Location, dungeon_config: dict, context: WorldGenerationContext) -> dict: + """Generate a dungeon entrance that fits the location and dungeon config.""" + prompt = llm_config.params['CREATE_DUNGEON_ENTRANCE_PROMPT'].format( + context='{context}', + location_name=location.name, + location_description=location.description, + dungeon_config=dungeon_config, + dungeon_entrance_template=llm_config.params['DUNGEON_ENTRANCE_TEMPLATE']) + + request_body = deepcopy(self.default_body) + if self.json_grammar_key: + request_body[self.json_grammar_key] = self.json_grammar + + result = self.io_util.synchronous_request(request_body, prompt=prompt, context=context.to_prompt_string()) + try: + return json_util.safe_load(result) + except json.JSONDecodeError as exc: + print(f'Error generating dungeon entrance: {exc}') + return None + except Exception as exc: + print(f'Error generating dungeon entrance: {exc}') + return None def _validate_creatures(self, creatures: dict) -> dict: new_creatures = {} diff --git a/tale/zone.py b/tale/zone.py index 443dda97..e6ec4081 100644 --- a/tale/zone.py +++ b/tale/zone.py @@ -4,6 +4,7 @@ if TYPE_CHECKING: from tale.dungeon.dungeon_config import DungeonConfig + from tale.dungeon.dungeon import Dungeon class Zone(): @@ -22,6 +23,7 @@ def __init__(self, name: str, description: str = '') -> None: self.name = name self.lore = "" self.dungeon_config = None # type: DungeonConfig + self.dungeon = None # type: Dungeon - tracks if a dungeon has been generated for this zone def add_location(self, location: Location) -> bool: """ Add a location to the zone. Skip if location already exists.""" diff --git a/tests/test_llm_utils.py b/tests/test_llm_utils.py index 581fb38c..230f4fe6 100644 --- a/tests/test_llm_utils.py +++ b/tests/test_llm_utils.py @@ -595,6 +595,123 @@ def test_generate_dungeon_locations(self): result = self.llm_util.generate_dungeon_locations(zone_info="", locations= [], depth= 1, max_depth=2) # type LocationDescriptionResponse assert len(result.location_descriptions) == 19 + def test_generate_dungeon_entrance(self): + """Test generating a dungeon entrance.""" + self.llm_util._world_building.io_util.response = '{"direction": "down", "name": "Dark Cave Entrance", "short_descr": "A dark cave entrance descending into the depths", "long_descr": "A foreboding entrance to a dark cave system. The air is cold and damp."}' + + location = Location(name='Mountain Pass', descr='A rocky mountain pass') + dungeon_config = { + "name": "Dark Caves", + "description": "A network of dark caves", + "races": ["bat", "spider"], + "items": ["torch"], + "max_depth": 3 + } + + self.llm_util.set_story(self.story) + result = self.llm_util.generate_dungeon_entrance(location, dungeon_config) + + assert result is not None + assert result.get('direction') == 'down' + assert result.get('name') == 'Dark Cave Entrance' + assert 'short_descr' in result + assert 'long_descr' in result + + def test_build_location_with_dungeon_generation(self): + """Test that build_location can generate a dungeon entrance.""" + from tale.dungeon.dungeon_config import DungeonConfig + + # Create a zone with dungeon config + zone = Zone('Test Zone', description='A test zone') + zone.dungeon_config = DungeonConfig( + name="Test Dungeon", + description="A test dungeon", + races=["bat"], + items=["torch"], + max_depth=3 + ) + zone_info = zone.get_info() + + # Set up responses: first for build_location, then for dungeon entrance + location_response = '{"name": "Forest Path", "description": "A path through the forest", "exits": [{"direction": "north", "name": "Dark Woods", "short_descr": "Dense dark woods"}], "items": [], "npcs": []}' + dungeon_entrance_response = '{"direction": "north", "name": "Cave Entrance", "short_descr": "A dark cave entrance", "long_descr": "An ominous cave entrance"}' + + self.llm_util._world_building.io_util.response = [location_response, dungeon_entrance_response] + self.llm_util.set_story(self.story) + + location = Location(name='Forest Path') + + # Mock random to always trigger dungeon generation + import random + original_random = random.random + random.random = lambda: 0.05 # Below 0.1 threshold + + try: + result, spawner = self.llm_util.build_location(location, 'start', zone_info, zone=zone) + + # Check that a dungeon was generated + if zone.dungeon: + assert zone.dungeon is not None + # Check that one of the exits is a DungeonEntrance + from tale.dungeon.DungeonEntrance import DungeonEntrance + has_dungeon_entrance = any(isinstance(exit, DungeonEntrance) for exit in result.exits) + assert has_dungeon_entrance + finally: + random.random = original_random + + def test_build_location_no_dungeon_without_config(self): + """Test that build_location doesn't generate dungeon without config.""" + zone = Zone('Test Zone', description='A test zone') + zone_info = zone.get_info() # No dungeon_config + + location_response = '{"name": "Forest Path", "description": "A path through the forest", "exits": [{"direction": "north", "name": "Dark Woods", "short_descr": "Dense dark woods"}], "items": [], "npcs": []}' + self.llm_util._world_building.io_util.response = location_response + self.llm_util.set_story(self.story) + + location = Location(name='Forest Path') + result, spawner = self.llm_util.build_location(location, 'start', zone_info, zone=zone) + + # Zone should not have a dungeon + assert zone.dungeon is None + + def test_build_location_no_dungeon_if_already_exists(self): + """Test that build_location doesn't generate another dungeon if one exists.""" + from tale.dungeon.dungeon_config import DungeonConfig + from tale.dungeon.dungeon import Dungeon + + zone = Zone('Test Zone', description='A test zone') + zone.dungeon_config = DungeonConfig( + name="Test Dungeon", + description="A test dungeon", + races=["bat"], + items=["torch"], + max_depth=3 + ) + # Simulate that dungeon already exists + zone.dungeon = object() # Just a placeholder + zone_info = zone.get_info() + + location_response = '{"name": "Forest Path", "description": "A path through the forest", "exits": [{"direction": "north", "name": "Dark Woods", "short_descr": "Dense dark woods"}], "items": [], "npcs": []}' + self.llm_util._world_building.io_util.response = location_response + self.llm_util.set_story(self.story) + + location = Location(name='Forest Path') + + # Mock random to always try dungeon generation + import random + original_random = random.random + random.random = lambda: 0.05 + + try: + result, spawner = self.llm_util.build_location(location, 'start', zone_info, zone=zone) + + # Check that no new DungeonEntrance was added (since dungeon already exists) + from tale.dungeon.DungeonEntrance import DungeonEntrance + has_dungeon_entrance = any(isinstance(exit, DungeonEntrance) for exit in result.exits) + assert not has_dungeon_entrance + finally: + random.random = original_random + class TestQuestBuilding(): From fc54f6c61a6d21cd5176098ae3fdf22c6fa4d8e7 Mon Sep 17 00:00:00 2001 From: rickard Date: Wed, 19 Nov 2025 19:05:07 +0100 Subject: [PATCH 13/15] fix broken test --- tests/test_llm_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_llm_config.py b/tests/test_llm_config.py index f9b2a885..a84c0190 100644 --- a/tests/test_llm_config.py +++ b/tests/test_llm_config.py @@ -74,4 +74,3 @@ def test_prompt_files_exist(self): # Check that there are .txt files in the directory txt_files = [f for f in os.listdir(prompts_dir) if f.endswith('.txt')] assert len(txt_files) > 0, "No .txt files found in prompt_templates directory" - assert len(txt_files) == 44, f"Expected 44 prompt files, found {len(txt_files)}" From aa799ee96a8216825e15110f29b9fc7e82c5b29d Mon Sep 17 00:00:00 2001 From: rickard Date: Wed, 19 Nov 2025 19:05:21 +0100 Subject: [PATCH 14/15] add back boss generation --- tale/dungeon/dungeon.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tale/dungeon/dungeon.py b/tale/dungeon/dungeon.py index 5fbe173b..1c3d01f1 100644 --- a/tale/dungeon/dungeon.py +++ b/tale/dungeon/dungeon.py @@ -8,6 +8,8 @@ import random from typing import TYPE_CHECKING +from stories.anything.npcs.npc_defs import RoamingMob +from tale import lang from tale.base import Door, Exit, Location from tale.coord import Coord from tale.dungeon.dungeon_generator import ItemPopulator, Layout, LayoutGenerator, MobPopulator @@ -103,6 +105,9 @@ def generate_level(self, zone: Zone, depth: int = 0) -> bool: # Add gold if not the first level if depth > 0: self._spawn_gold(zone=zone) + + if zone.center.z == self.max_depth: + self._generate_boss(zone=zone) self.zones.append(zone) return True @@ -244,5 +249,23 @@ def get_entrance_location(self) -> Location: if not self.zones or not self.zones[0].locations: return None return list(self.zones[0].locations.values())[0] + + + def _generate_boss(self, zone: Zone) -> bool: + character = self.llm_util.generate_character(keywords=['final boss']) # Characterv2 + if character: + boss = RoamingMob(character.name, + gender=character.gender, + title=lang.capital(character.name), + descr=character.description, + short_descr=character.appearance, + age=character.age, + personality=character.personality) + boss.aliases = [character.name.split(' ')[0]] + boss.stats.level = self.max_depth + location = random.choice(list(zone.locations.values())) + location.insert(boss, None) + return True + return False From bd4e293e246c0b5283f608ac10efd94bd0d2ba9e Mon Sep 17 00:00:00 2001 From: rickard Date: Sat, 22 Nov 2025 13:02:09 +0100 Subject: [PATCH 15/15] dungeon generation fixes --- stories/dungeon/story.py | 16 ---------------- tale/dungeon/dungeon.py | 16 +++++++++------- tale/dungeon/dungeon_generator.py | 5 +++-- tale/zone.py | 2 +- 4 files changed, 13 insertions(+), 26 deletions(-) diff --git a/stories/dungeon/story.py b/stories/dungeon/story.py index fff891bf..3d6aacf5 100644 --- a/stories/dungeon/story.py +++ b/stories/dungeon/story.py @@ -126,22 +126,6 @@ def add_zone(self, zone: Zone) -> bool: return True - def _generate_boss(self, zone: Zone) -> bool: - character = self.llm_util.generate_character(keywords=['final boss']) # Characterv2 - if character: - boss = RoamingMob(character.name, - gender=character.gender, - title=lang.capital(character.name), - descr=character.description, - short_descr=character.appearance, - age=character.age, - personality=character.personality) - boss.aliases = [character.name.split(' ')[0]] - boss.stats.level = self.max_depth - location = random.choice(list(zone.locations.values())) - location.insert(boss, None) - return True - return False if __name__ == "__main__": # story is invoked as a script, start it in the Tale Driver. diff --git a/tale/dungeon/dungeon.py b/tale/dungeon/dungeon.py index 1c3d01f1..4f1c6080 100644 --- a/tale/dungeon/dungeon.py +++ b/tale/dungeon/dungeon.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from stories.anything.npcs.npc_defs import RoamingMob -from tale import lang +from tale import lang, parse_utils from tale.base import Door, Exit, Location from tale.coord import Coord from tale.dungeon.dungeon_generator import ItemPopulator, Layout, LayoutGenerator, MobPopulator @@ -201,27 +201,29 @@ def _connect_locations(self, layout: Layout) -> None: for connection in connections: cell_location = self._grid.get(connection.coord.as_tuple(), None) parent_location = self._grid.get(connection.other.as_tuple(), None) + direction = parse_utils.direction_from_coordinates(connection.other.subtract(connection.coord)) + reverse_direction = parse_utils.opposite_direction(direction) if not cell_location or not parent_location: continue # Skip if already connected - if cell_location.exits.get(parent_location.name, None): + if cell_location.exits.get(parent_location.name, None) or cell_location.exits.get(direction, None): continue - elif parent_location.exits.get(cell_location.name, None): + elif parent_location.exits.get(cell_location.name, None) or parent_location.exits.get(reverse_direction, None): continue # Create connection if connection.door: Door.connect( - cell_location, parent_location.name, '', None, - parent_location, cell_location.name, '', None, + cell_location, [parent_location.name, direction], '', None, + parent_location, [cell_location.name, reverse_direction], '', None, opened=False, locked=connection.locked, key_code=connection.key_code ) else: Exit.connect( - cell_location, parent_location.name, '', None, - parent_location, cell_location.name, '', None + cell_location, [parent_location.name, direction], '', None, + parent_location, [cell_location.name, reverse_direction], '', None ) def _spawn_gold(self, zone: Zone): diff --git a/tale/dungeon/dungeon_generator.py b/tale/dungeon/dungeon_generator.py index bf3737ea..50b6139a 100644 --- a/tale/dungeon/dungeon_generator.py +++ b/tale/dungeon/dungeon_generator.py @@ -223,7 +223,8 @@ def populate(self, layout: 'Layout', story: DynamicStory, zone: Zone) -> list: if not mob_type: continue mob_type['level'] = zone.level - item_probabilities = [random.random() * 0.15 + 0.5 for i in range(len(zone.items))] + item_types = [zone.items] + item_probabilities = [(random.random() * 0.15 + 0.5) for i in range(len(item_types))] mob_spawner = MobSpawner(location=location, mob_type=mob_type, spawn_rate=30, spawn_limit=2, drop_items=zone.items, drop_item_probabilities=item_probabilities) mob_spawners.append(mob_spawner) if len(mob_spawners) == 1: @@ -249,7 +250,7 @@ def populate(self, zone: Zone, story: DynamicStory) -> list: continue item_type['level'] = zone.level item_types = [item_type] - item_probabilities = [random.random() * 0.15 + 0.5 for i in range(len(zone.items))] + item_probabilities = [(random.random() * 0.15 + 0.5) for i in range(len(item_types))] item_spawners.append(ItemSpawner(zone=zone, items=item_types, item_probabilities=item_probabilities, spawn_rate=30)) if len(item_spawners) == 1: return [item_spawners] diff --git a/tale/zone.py b/tale/zone.py index e6ec4081..5bfd43d9 100644 --- a/tale/zone.py +++ b/tale/zone.py @@ -23,7 +23,7 @@ def __init__(self, name: str, description: str = '') -> None: self.name = name self.lore = "" self.dungeon_config = None # type: DungeonConfig - self.dungeon = None # type: Dungeon - tracks if a dungeon has been generated for this zone + self.dungeon = None # type: Dungeon def add_location(self, location: Location) -> bool: """ Add a location to the zone. Skip if location already exists."""