Skip to content

Commit

Permalink
Merge pull request #51 from mmacy/da-prompt-udpates
Browse files Browse the repository at this point in the history
update prompts for better location keywords + test code example ingestion
  • Loading branch information
mmacy committed Feb 6, 2024
2 parents 6d81ea8 + b56d62e commit 8b7ed6b
Show file tree
Hide file tree
Showing 10 changed files with 249 additions and 201 deletions.
38 changes: 19 additions & 19 deletions osrgame/osrgame/osrgame.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ class OSRConsole(App):
"""The OSR Console application."""
player_party = None
adventure = None
dungeon_master = None
openai_model = OpenAIModelVersion.DEFAULT # Use any value other than NONE to enable AI
dungeon_assistant = None

# To disable AI, set this to OpenAIModelVersion.NONE
openai_model = OpenAIModelVersion.GPT35TURBO
num_dungeon_locations = 10

CSS_PATH = "screen.tcss"

Expand Down Expand Up @@ -63,22 +66,19 @@ def set_active_adventure(self, adventure: Adventure = None) -> None:
default_adventure = Adventure(random.choice(ADVENTURE_NAMES))
default_adventure.description = "An adventure for 4-6 characters of levels 1-3."
default_adventure.introduction = (
"In the heart of the cursed Mystic Forest, a tale as old as time stirs once again. Legends "
"speak of Glofarnux, an ancient wizard lich whose thirst for arcane knowledge knew no bounds. The entrance to the "
"underground complex he once called home--but for centuries been is tomb--has recently been found. Known now as the "
"'Dungeon of the Mad Mage,' its entrance is concealed within a seemingly natural rock outcropping in a secluded glade "
"deep in the Mystic Forest. Brave adventurers, your party, have summone to help unravel the mysteries in depths of "
"the forgotten subterranean citadel. Within its depth, echoes of the past mingle with the shadows of the present, "
"challenging all who dare to attempt to learn the secrets of Glofarnux and his once noble but now twisted arcane "
"magic. Your party stands ready in the oppressive silence of the lost glade in the Mystic Forest, just outside the "
"once magically concealed outcropping of rock and its now visible entrance open to the depths of the Dungeon of the "
"Mad Mage."
"Deep underground in the heart of the Mystic Forest lives Glofarnux, an ancient wizard lich "
"whose thirst for arcane knowledge knew no bounds. The entrance to the underground complex he once called "
"home--but has for centuries been his tomb--was recently found concealed in a seemingly natural rock "
"outcropping deep in forest. Your party of adventurers have been summoned to help unravel the mysteries "
"of Glofarnux's subterranean citadel by learning the secrets of Glofarnux and his once noble but now "
"twisted arcane magic. Your party stands ready in the oppressive silence of the forest, just outside the "
"once magically hidden entrance now open to the depths of the dungeon."
)

dungeon = Dungeon.get_random_dungeon(random.choice(DUNGEON_NAMES),
"The first level of the home of the ancient wizard lich Glofarnux, its "
"entrance hidden in a forgotten glade deep in the cursed Mystic Forest.",
num_locations=50, openai_model=self.openai_model)
"The first level of the home of the ancient wizard lich Glofarnux, "
"its entrance hidden in a glade deep in the Mystic Forest.",
num_locations=self.num_dungeon_locations, openai_model=self.openai_model)
dungeon.set_start_location(1)

if dungeon.validate_location_connections():
Expand All @@ -95,13 +95,13 @@ def start_session(self) -> str:
if self.adventure is None:
self.set_active_adventure(adventure=None)

self.dungeon_master = DungeonAssistant(self.adventure, openai_model=self.openai_model)
dm_start_session_response = self.dungeon_master.start_session()
self.dungeon_assistant = DungeonAssistant(self.adventure, openai_model=self.openai_model)
dm_start_session_response = self.dungeon_assistant.start_session()
logger.debug(f"DM start session response: {dm_start_session_response}")

# Move the party to the first location
first_exit = self.dungeon_master.adventure.active_dungeon.get_location_by_id(1).exits[0]
dm_first_party_move_response = self.dungeon_master.move_party(first_exit.direction)
first_exit = self.dungeon_assistant.adventure.active_dungeon.get_location_by_id(1).exits[0]
dm_first_party_move_response = self.dungeon_assistant.move_party(first_exit.direction)
logger.debug(f"DM first PC move response: {dm_first_party_move_response}")

return dm_start_session_response + "\n" + dm_first_party_move_response
Expand Down
30 changes: 15 additions & 15 deletions osrgame/osrgame/screen_explore.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class ExploreScreen(Screen):
("ctrl+s", "save_game", "Save game"),
]

dungeon_master = None
dungeon_assistant = None

def compose(self) -> ComposeResult:
yield Header(show_clock=True)
Expand All @@ -30,7 +30,7 @@ def compose(self) -> ComposeResult:
yield Footer()

def on_mount(self) -> None:
self.dungeon_master = self.app.dungeon_master
self.dungeon_assistant = self.app.dungeon_assistant
self.query_one("#player_log", Log).border_title = "Command Log"
self.query_one("#dm_log", Log).border_title = "Adventure Log"
self.query_one(PartyRosterTable).border_title = "Adventuring Party"
Expand All @@ -42,10 +42,10 @@ def perform_move_action(self, direction: Direction, log_message: str) -> None:

self.query_one("#player_log").write_line(log_message)

dm_response = self.dungeon_master.move_party(direction)
dm_response = self.dungeon_assistant.move_party(direction)

self.query_one("#dm_log").write_line(
""#"> " + str(self.dungeon_master.adventure.active_dungeon.current_party_location)
""#"> " + str(self.dungeon_assistant.adventure.active_dungeon.current_party_location)
)
self.query_one("#dm_log").write_line(wrap_text(dm_response))

Expand All @@ -54,11 +54,11 @@ def perform_move_action(self, direction: Direction, log_message: str) -> None:
def check_for_encounter(self) -> None:
"""Check for an encounter and execute battle if there are monsters in the encounter."""
if (
self.dungeon_master.adventure.active_dungeon.current_party_location.encounter
and not self.dungeon_master.adventure.active_dungeon.current_party_location.encounter.is_ended
self.dungeon_assistant.adventure.active_dungeon.current_party_location.encounter
and not self.dungeon_assistant.adventure.active_dungeon.current_party_location.encounter.is_ended
):
encounter = (
self.dungeon_master.adventure.active_dungeon.current_party_location.encounter
self.dungeon_assistant.adventure.active_dungeon.current_party_location.encounter
)

if (
Expand All @@ -71,9 +71,9 @@ def check_for_encounter(self) -> None:
# TODO: Check whether monsters were surprised, and if so, give the player a chance to flee.
self.query_one("#player_log").write_line("> Fight!")

encounter.start_encounter(self.dungeon_master.adventure.active_party)
encounter.start_encounter(self.dungeon_assistant.adventure.active_party)
encounter_log = encounter.get_encounter_log()
dm_response = self.dungeon_master.summarize_battle(encounter_log)
dm_response = self.dungeon_assistant.summarize_battle(encounter_log)
self.query_one("#dm_log").write_line(wrap_text(dm_response))

self.query_one("#pc_party_table").update_table()
Expand Down Expand Up @@ -115,13 +115,13 @@ def clear_logs(self) -> None:
def action_summarize(self) -> None:
"""An action to summarize the session."""
self.query_one("#player_log").write_line("> Describe location")
formatted_message = self.dungeon_master.format_user_message(
formatted_message = self.dungeon_assistant.format_user_message(
"Please describe this location again, including specifying the exit that we entered from and which exit or exits, if any, we haven't yet explored: " \
+ str(self.dungeon_master.adventure.active_dungeon.current_party_location)
+ str(self.dungeon_assistant.adventure.active_dungeon.current_party_location)
)
dm_response = self.dungeon_master.player_message(formatted_message)
dm_response = self.dungeon_assistant.send_player_message(formatted_message)
self.query_one("#dm_log").write_line(
""#"> " + str(self.dungeon_master.adventure.active_dungeon.current_party_location)
""#"> " + str(self.dungeon_assistant.adventure.active_dungeon.current_party_location)
)
self.query_one("#dm_log").write_line(wrap_text(dm_response))
self.query_one("#dm_log").write_line("---")
Expand All @@ -133,12 +133,12 @@ def action_character(self) -> None:
def action_heal_party(self) -> None:
"""An action to heal the party."""
self.query_one("#player_log").write_line("> Heal party")
self.dungeon_master.adventure.active_party.heal_party()
self.dungeon_assistant.adventure.active_party.heal_party()
self.query_one("#player_log").write_line(" Party healed.")
self.query_one("#pc_party_table").update_table()

def action_save_game(self) -> None:
"""An action to save the game."""
self.query_one("#player_log").write_line("> Save adventure")
save_path = self.dungeon_master.adventure.save_adventure()
save_path = self.dungeon_assistant.adventure.save_adventure()
self.query_one("#player_log").write_line(f" Saved to: {save_path}")
2 changes: 1 addition & 1 deletion osrgame/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 42 additions & 40 deletions osrlib/osrlib/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,47 +189,49 @@


ADVENTURE_NAMES = [
"Ancyenp Wyzord's Ryddle",
"Arcone Shodowf",
"Curfed Grode Expedypyon",
"Depphf of Myfpycyfm",
"Achoef of Mogyc",
"Anygmo of Depphf",
"Forbydden Arcono",
"Forgoppen Cypodel Journey",
"Grofornux's Legocy",
"Grofornux's Reckonyng",
"Hounped Forefp Trek",
"Hydden Reolmf",
"Lych's Awokenyng",
"Lofp Grode Advenpure",
"Myfpyc Forefp Quefp",
"Myfpyc Sylence",
"Subperroneon Secrepf",
"Tole of phe Mod Moge",
"Twylyghp of Grofornux",
"Wyzord's Lofp Domoyn",
"Ancient Wizard's Riddle",
"Arcane Shadow",
"Cursed Glade Expedition",
"Depths of Mysticism",
"Echoes of Magic",
"Enigma of Depths",
"Forbidden Arcana",
"Forgotten Crypt Journey",
"Glofarnux's Legacy",
"Glofarnux's Reckoning",
"Haunted Forest Trek",
"Hidden Realms",
"Lich's Awakening",
"Lost Glade Adventure",
"Mystic Forest Quest",
"Mystic Silence",
"Subterranean Secrets",
"Tale of the Mad Mage",
"Twilight of Glofarnux",
"Wizard's Lost Domain",
]
"""List of names for use when creating a random [Adventure][osrlib.adventure.Adventure]."""

DUNGEON_NAMES = [
"Ancyenp Wyzord's Hold",
"Arcone Underground",
"Curfed Lobyrynph",
"Achoyng Copocombf",
"Anchonped Chomberf",
"Fornuxyum Depphf",
"Forbydden Undercrofp",
"Forefp Heorp Dungeon",
"Grofornux's Bofpyon",
"Grofornux's Chomberf",
"Grofornux's Tomb",
"Lych's Hydden Spronghold",
"Lofp Wyzord's Domoyn",
"Mod Moge's Loyr",
"Myfpyc Groppo",
"Myfpyc Holrowf",
"Myfpyc Moze",
"Secluded Wyzord's Keep",
"Shodowed Hollf",
"Veyled Soncpum",
"Ancient Wizard's Hold",
"Arcane Underground",
"Cursed Labyrinth",
"Echoing Catacombs",
"Anchored Chambers",
"Fornixium Depths",
"Forbidden Undercroft",
"Forest Heart Dungeon",
"Glofarnux's Bastion",
"Glofarnux's Chambers",
"Glofarnux's Tomb",
"Lich's Hidden Stronghold",
"Lost Wizard's Domain",
"Mad Mage's Lair",
"Mystic Grotto",
"Mystic Hollows",
"Mystic Maze",
"Secluded Wizard's Keep",
"Shadowed Halls",
"Veiled Sanctum",
]
"""List of names for use when creating a random [Dungeon][osrlib.dungeon.Dungeon]."""
77 changes: 53 additions & 24 deletions osrlib/osrlib/dungeon.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,8 @@ class Exit:
exits collection.
Example:
```python
>>> exit1 = Exit(Direction.NORTH, 2)
>>> exit2 = Exit(Direction.SOUTH, 1)
>>> exit1.lock()
>>> exit2.unlock()
--8<-- "tests/test_unit_dungeon.py:dungeon_exit_create"
```
"""

Expand Down Expand Up @@ -484,11 +480,11 @@ def validate_location_connections(self) -> bool:
return len(validation_errors) == 0

@staticmethod
def get_dungeon_location_keywords(
def _get_location_keywords_from_llm(
dungeon: "Dungeon",
openai_model: OpenAIModelVersion = OpenAIModelVersion.DEFAULT,
) -> str:
"""Get the keywords for each [Location][osrlib.dungeon.Location] in the dungeon from the OpenAI API.
"""Get the keywords for each `Location` in the dungeon from the OpenAI API.
Provided a `Dungeon`, gets a list of keywords for its locations from the OpenAI API. The list of keywords for
each location are formatted as a JSON collection and returned to the caller. The OpenAI language model uses the
Expand All @@ -502,18 +498,19 @@ def get_dungeon_location_keywords(
system_message = [
{
"role": "system",
"content": "You are the Dungeon Master component in a turn-based RPG. You help players envision and "
"experience the environments they explore through your descriptions of the locations they visit. "
"You will be provided a dungeon's name, its description, and the number of locations in the dungeon. "
"Your task is to generate four descriptive keywords for each location that will help players "
"visualize and add the location to their map. Your response must be in JSON. You should consider the "
"dungeon's description and your previously generated locations' keywords to ensure a consistent theme "
"across the locations in the dungeon. Keep in mind that adjacent integers typically represent adjacent "
"locations in the dungeon, and their keywords should reflect that relationship. The JSON response "
"should be a collection of key-value pairs where the key is the location ID and the value is the "
"collection of keywords for that location. The JSON must include keywords for every location and no two "
"locations should have the same keywords. Every location must have four keywords, and the word 'whisper' "
"must never be used, nor should 'footsteps' or 'dripping'.",
"content": "You are the Dungeon Creator component in a turn-based RPG. You help adventurers picture the "
"locations they visit iin the game world. You will be provided a dungeon's name and description as well "
"as the number of locations in the dungeon. Your task is "
"to generate three single keywords describing each location in the dungeon. Your response must be in "
"in JSON. Maintain the theme of the dungeon and always consider previous location's keywords, especially "
"those of adjacent locations. Adjacent integers typically represent adjacent locations, and their "
"keywords should reflect a logical architectual relationship. The JSON response should be a collection "
"of key-value pairs where the key is the location ID and the value is the collection of keywords for "
"that location. The JSON must include keywords for every location and no two locations should share "
"keywords. You should only include keywords that describe characteristics of the physical environment. "
"Use single keywords. These keywords are banned and you should not include them or any form of them: "
"whisper, drip, monster, echo, stairs, staircase, door, treasure, cursed, enchanted, sinister, "
"ominous, mysterious, forgotten, creepy, stairway",
},
]
user_message = [
Expand Down Expand Up @@ -558,6 +555,15 @@ def get_random_dungeon(
Returns:
Dungeon: A randomly generated dungeon with the specified number of locations, each with a random size and
possibly containing an [Encounter][osrlib.encounter.Encounter].
Examples:
```python
--8<-- "tests/test_unit_dungeon.py:dungeon_get_random_no_keywords"
```
```python
--8<-- "tests/test_unit_dungeon.py:dungeon_get_random_with_ai_keywords"
```
"""
if num_locations < 1:
raise ValueError("Dungeon must have at least one location.")
Expand Down Expand Up @@ -604,22 +610,45 @@ def get_random_dungeon(

dungeon = Dungeon(name, description, locations, start_location_id=1)

Dungeon._populate_location_keywords(dungeon, openai_model)

return dungeon

@staticmethod
def _populate_location_keywords(dungeon: "Dungeon", openai_model: OpenAIModelVersion = OpenAIModelVersion.NONE):
"""Populates each location in the dungeon with keywaords obtained from the OpenAI API.
Args:
dungeon (Dunegon): The `Dungeon` whose locations should be populated with keyords.
openai_model (OpenAIModelVersion): The version of the OpenAI model to use when generating keywords.
"""
if openai_model is not OpenAIModelVersion.NONE:
location_keywords_json = Dungeon.get_dungeon_location_keywords(dungeon, openai_model)
location_keywords_json = Dungeon._get_location_keywords_from_llm(dungeon, openai_model)
location_keywords_dict = json.loads(location_keywords_json)
for location_id_str, keywords in location_keywords_dict.items():
location_id = int(location_id_str)
location = dungeon.get_location_by_id(location_id)
if location:
location.keywords = keywords

return dungeon
def to_json(self) -> str:
"""Gets a JSON string representation of the `Dungeon` instance.
This method uses the `to_dict` method to first convert the dungeon instance into a
dictionary, which is then serialized into a JSON string using `json.dumps`. ]]
Returns:
str: A JSON string representation of the dungeon instance.
def to_json(self):
"""Returns a JSON representation of the dungeon."""
Example:
```python
--8<-- "tests/test_unit_dungeon.py:dungeon_to_from_json"
```
"""
return json.dumps(self.to_dict(), default=lambda o: o.__dict__)

def to_dict(self):

def to_dict(self) -> dict:
"""Returns a dictionary representation of the dungeon. Useful as a pre-serialization step when saving to a permanent data store."""
return {
"name": self.name,
Expand Down
Loading

0 comments on commit 8b7ed6b

Please sign in to comment.