From 94df7fe30dc579ed6d72cb2f54707fb130df9685 Mon Sep 17 00:00:00 2001 From: rickard Date: Sat, 19 Aug 2023 08:23:39 +0200 Subject: [PATCH 1/3] location generation basics move llm_config to root --- tale/llm_config.yaml => llm_config.yaml | 8 ++- tale/base.py | 1 + tale/driver.py | 1 + tale/llm_utils.py | 83 +++++++++++++++++-------- tale/parse_utils.py | 4 +- tale/player.py | 2 +- 6 files changed, 66 insertions(+), 33 deletions(-) rename tale/llm_config.yaml => llm_config.yaml (75%) diff --git a/tale/llm_config.yaml b/llm_config.yaml similarity index 75% rename from tale/llm_config.yaml rename to llm_config.yaml index c9a2803d..d80ce369 100644 --- a/tale/llm_config.yaml +++ b/llm_config.yaml @@ -3,7 +3,7 @@ ENDPOINT: "/api/v1/generate" STREAM: False STREAM_ENDPOINT: "/api/extra/generate/stream" DATA_ENDPOINT: "/api/extra/generate/check" -WORD_LIMIT: 300 +WORD_LIMIT: 200 DEFAULT_BODY: '{"stop_sequence": "", "max_length":500, "max_context_length":4096, "temperature":1.0, "top_k":120, "top_a":0.0, "top_p":0.85, "typical_p":1.0, "tfs":1.0, "rep_pen":1.2, "rep_pen_range":256, "mirostat":2, "mirostat_tau":5.0, "mirostat_eta":0.1, "sampler_order":[6,0,1,3,4,2,5], "seed":-1}' ANALYSIS_BODY: '{"banned_tokens":"\n\n", "stop_sequence": "\n\n\n", "max_length":500, "max_context_length":4096, "temperature":0.15, "top_k":120, "top_a":0.0, "top_p":0.85, "typical_p":1.0, "tfs":1.0, "rep_pen":1.2, "rep_pen_range":256, "mirostat":2, "mirostat_tau":5.0, "mirostat_eta":0.1, "sampler_order":[6,0,1,3,4,2,5], "seed":-1}' MEMORY_SIZE: 512 @@ -13,5 +13,7 @@ ACTION_PROMPT: "[Story context: {story_context}]; [History: {history}]; The foll DIALOGUE_PROMPT: '[Story context: {story_context}]; [Location: {location}]; The following is a conversation between {character1} and {character2}; {character1}:{character1_description}; character2:{character2_description}; [Chat history: {previous_conversation}]\n\n [{character2}s sentiment towards {character1}: {sentiment}]. ### Instruction: Write a single response for {character2} in third person pov, using {character2} description.\n\n### Response:\n' ITEM_PROMPT: 'Items:[{items}];Characters:[{character1},{character2}] \n\n ### Instruction: Decide if an item was explicitly given, taken, dropped or put somewhere in the following text:[Text:{text}]. Insert your thoughts about whether an item was explicitly given, taken, put somewhere or dropped in "my thoughts", and the results in "item", "from" and "to", or make them empty if . Insert {character1}s sentiment towards {character2} in a single word in "sentiment assessment". Fill in the following JSON template: {{ "thoughts":"my thoughts", "result": {{ "item":"", "from":"", "to":""}}, {{"sentiment":"sentiment assessment"}} }} End of example. \n\n Write your response in valid JSON\n\n### Response:\n' COMBAT_PROMPT: 'Rewrite the following combat between user {attacker}, weapon:{attacker_weapon} and {victim}, weapon:{victim_weapon} into a vivid description in less than 300 words. Location: {location}, {location_description}. ### Instruction: Rewrite the following combat result in about 150 words, using the characters weapons . Combat Result: {attacker_msg} ### Response:\n\n' -CREATE_CHARACTER_PROMPT: '### Instruction: For a low-fantasy roleplaying game, create a diverse character with rich personality that can be interacted with using the story context and keywords. Do not mention height. Money as int value. Story context: {story_context}; keywords: {keywords}. Fill in this JSON template and write nothing else: {{"name":"", "description": "", "appearance": "", "personality": "", "money":"", "level":"", "gender":"", "age":"", "race":""}} ### Response:\n' -CREATE_ROOM_PROMPT: 'Story context: {story_context}. Exit template: {"name" : "connected room", "short_desc":"description visible to use", "long_desc":"detailed description", "enter_msg":"message when using exit"}; Item template: {"name":"", "type":""}, type can be "weapon", "armor", "other"; Character template: {"name":"", "type":""}, type can be "friendly", "neutral", "hostile" ### Instruction: Existing room connected to the generated room: {room_desc}. For a {story_type} roleplaying game, create a single new, intriguing, room connected to {room_name}. Include at least one exit leading to an additional room. Add items and characters if if seems fitting. Fill in this JSON template and do not write anything else: {"name":"", "description": "", "exits": [], "items":[], "npcs":[]} ### Response:\n' \ No newline at end of file +CREATE_CHARACTER_PROMPT: '### Instruction: For a low-fantasy roleplaying game, create a diverse character with rich personality that can be interacted with using the story context and keywords. Do not mention height. Story context: {story_context}; keywords: {keywords}. Fill in this JSON template and write nothing else: {{"name":"", "description": "", "appearance": "", "personality": "", "money":(int), "level":"", "gender":"m/f/n", "age":(int), "race":""}} ### Response:\n' +CREATE_LOCATION_PROMPT: '[Story context: {story_context}]; Exit example: {"name" : "connected room", "short_desc":"description visible to use", "long_desc":"detailed description", "enter_msg":"message when using exit"}; Item example: {"name":"", "type":""}, type can be "Weapon", "Armor", "Other" or "Money"; Character example: {"name":"", "type":""}, type can be "friendly", "neutral", "hostile". Existing connected room: {exit_room}. ### Instruction: For a {story_type}, describe the following room: {room_name}. Add a description and include at least one exit leading to an additional room. Fill in this JSON template and do not write anything else: {"name":"", "description": "", "exits": [], "items":[], "npcs":[]}. Make sure the response is valid JSON. ### Response:\n' + + diff --git a/tale/base.py b/tale/base.py index 2ad8a630..1c50ce58 100644 --- a/tale/base.py +++ b/tale/base.py @@ -607,6 +607,7 @@ def __init__(self, name: str, descr: str="") -> None: self.exits = {} # type: Dict[str, Exit] # dictionary of all exits: exit_direction -> Exit object with target & descr super().__init__(name, descr=descr) self.name = name # make sure we preserve the case; base object overwrites it in lowercase + self.built = True # has this location been built yet? If not, LLM will describe it. def __contains__(self, obj: Union['Living', Item]) -> bool: return obj in self.livings or obj in self.items diff --git a/tale/driver.py b/tale/driver.py index 9dc53ad3..aa645605 100644 --- a/tale/driver.py +++ b/tale/driver.py @@ -792,6 +792,7 @@ def load_character(self, player: player.Player, path: str): short_descr = character.appearance.split('.')[0], age = character.age, personality = character.personality, + race = character.race, occupation = character.occupation) npc.following = player npc.stats.hp = character.hp diff --git a/tale/llm_utils.py b/tale/llm_utils.py index c635e126..5924fe66 100644 --- a/tale/llm_utils.py +++ b/tale/llm_utils.py @@ -2,6 +2,7 @@ import os import yaml from json import JSONDecodeError +from tale.base import Location from tale.llm_io import IoUtil from tale.load_character import CharacterV2 from tale.player_utils import TextBuffer @@ -11,7 +12,7 @@ class LlmUtil(): """ Prepares prompts for various LLM requests""" def __init__(self): - with open(os.path.realpath(os.path.join(os.path.dirname(__file__), "llm_config.yaml")), "r") as stream: + with open(os.path.realpath(os.path.join(os.path.dirname(__file__), "../llm_config.yaml")), "r") as stream: try: config_file = yaml.safe_load(stream) except yaml.YAMLError as exc: @@ -29,6 +30,7 @@ def __init__(self): self.action_prompt = config_file['ACTION_PROMPT'] self.combat_prompt = config_file['COMBAT_PROMPT'] self.character_prompt = config_file['CREATE_CHARACTER_PROMPT'] + self.location_prompt = config_file['CREATE_LOCATION_PROMPT'] self.item_prompt = config_file['ITEM_PROMPT'] self.word_limit = config_file['WORD_LIMIT'] self._story_background = '' @@ -37,32 +39,34 @@ def __init__(self): self.connection = None def evoke(self, player_io: TextBuffer, message: str, max_length : bool=False, rolling_prompt='', alt_prompt='', skip_history=True): - if len(message) > 0 and str(message) != "\n": - trimmed_message = parse_utils.remove_special_chars(str(message)) - base_prompt = alt_prompt if alt_prompt else self.base_prompt - amount = 25 #int(len(trimmed_message) / 2) - prompt = self.pre_prompt - prompt += base_prompt.format( - story_context=self._story_background, - history=rolling_prompt if not skip_history or alt_prompt else '', - max_words=self.word_limit if not max_length else amount, - input_text=str(trimmed_message)) - - rolling_prompt = self.update_memory(rolling_prompt, trimmed_message) - - request_body = self.default_body - request_body['prompt'] = prompt + """Evoke a response from LLM. Async if stream is True, otherwise synchronous.""" + """Update the rolling prompt with the latest message.""" + if not message or str(message) == "\n": + str(message), rolling_prompt + trimmed_message = parse_utils.remove_special_chars(str(message)) + base_prompt = alt_prompt if alt_prompt else self.base_prompt + amount = 25 #int(len(trimmed_message) / 2) + prompt = self.pre_prompt + prompt += base_prompt.format( + story_context=self._story_background, + history=rolling_prompt if not skip_history or alt_prompt else '', + max_words=self.word_limit if not max_length else amount, + input_text=str(trimmed_message)) + + rolling_prompt = self.update_memory(rolling_prompt, trimmed_message) + + request_body = self.default_body + request_body['prompt'] = prompt - if not self.stream: - text = self.io_util.synchronous_request(self.url + self.endpoint, request_body) - rolling_prompt = self.update_memory(rolling_prompt, text) - return f'Original:[ {message} ]\nGenerated:\n{text}', rolling_prompt - else: - player_io.print(f'Original:[ {message} ]\nGenerated:\n', end=False, format=True, line_breaks=False) - text = self.io_util.stream_request(self.url + self.stream_endpoint, self.url + self.data_endpoint, request_body, player_io, self.connection) - rolling_prompt = self.update_memory(rolling_prompt, text) - return '\n', rolling_prompt - return str(message), rolling_prompt + if not self.stream: + text = self.io_util.synchronous_request(self.url + self.endpoint, request_body) + rolling_prompt = self.update_memory(rolling_prompt, text) + return f'Original:[ {message} ]\nGenerated:\n{text}', rolling_prompt + else: + player_io.print(f'Original:[ {message} ]\nGenerated:\n', end=False, format=True, line_breaks=False) + text = self.io_util.stream_request(self.url + self.stream_endpoint, self.url + self.data_endpoint, request_body, player_io, self.connection) + rolling_prompt = self.update_memory(rolling_prompt, text) + return '\n', rolling_prompt def generate_dialogue(self, conversation: str, character_card: str, @@ -95,6 +99,7 @@ def generate_dialogue(self, conversation: str, return f'{text}', item_handling_result, new_sentiment def dialogue_analysis(self, text: str, character_card: str, character_name: str, target: str): + """Parse the response from LLM and determine if there are any items to be handled.""" items = character_card.split('items:')[1].split(']')[0] prompt = self.generate_item_prompt(text, items, character_name, target) request_body = self.analysis_body @@ -129,7 +134,6 @@ def generate_item_prompt(self, text: str, items: str, character1: str, character return prompt def validate_item_response(self, json_result: dict, character1: str, character2: str, items: str) -> bool: - if 'result' not in json_result or not json_result.get('result'): return False, None result = json_result['result'] @@ -168,6 +172,31 @@ def generate_character(self, story_context: str = '', keywords: list = []): print(f'Exception while parsing character {json_result}') return None + def build_location(self, location: Location, exit_location: Location): + """ Generate a location based on the current story context""" + prompt = self.location_prompt.format( + story_type=self._story_type, + story_context=self._story_background, + exit_location=exit_location.name, + location_name=location.name) + request_body = self.default_body + request_body['stop_sequence'] = ['\n\n'] + request_body['temperature'] = 1.0 + request_body['banned_tokens'] = ['```'] + request_body['prompt'] = prompt + result = self.io_util.synchronous_request(self.url + self.endpoint, request_body) + try: + json_result = json.loads(parse_utils.sanitize_json(result)) + #should be a location in json format + except JSONDecodeError as exc: + print(exc) + return None + try: + return location.from_json(json_result) + except: + print(f'Exception while parsing location {json_result}') + return None + @property def story_background(self) -> str: return self._story_background diff --git a/tale/parse_utils.py b/tale/parse_utils.py index 18837bb5..7d6c5d44 100644 --- a/tale/parse_utils.py +++ b/tale/parse_utils.py @@ -128,8 +128,8 @@ def set_note(note: Note, item: dict): note.text = item['text'] def remove_special_chars(message: str): - re.sub('[^A-Za-z0-9 .,_\-\'\"]+', '', message) - return message + re.sub('[^A-Za-z0-9 .,_\-\'\"]+', '', message) + return message def trim_response(message: str): enders = ['.', '!', '?', '`', '*', '"', ')', '}', '`', ']'] diff --git a/tale/player.py b/tale/player.py index ee5b0ba5..14cc886c 100644 --- a/tale/player.py +++ b/tale/player.py @@ -45,7 +45,7 @@ def __init__(self, name: str, gender: str, *, race: str="human", descr: str="", self.init_nonserializables() self.rolling_prompt = '' self.stats.hp = 10 - self.look_hashes = dict() + self.look_hashes = dict() # type: Dict[int, str] # location hashes for look command. currently never cleared. def init_nonserializables(self) -> None: # these things cannot be serialized or have to be reinitialized From f9c0f643813f817cbe4eff05bc4fa9771eab82f9 Mon Sep 17 00:00:00 2001 From: rickard Date: Sun, 20 Aug 2023 13:22:20 +0200 Subject: [PATCH 2/3] some wearable basics --- tale/base.py | 16 +++ tale/cmds/normal.py | 30 ++++- tale/parse_utils.py | 12 +- tale/wearable.py | 263 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 tale/wearable.py diff --git a/tale/base.py b/tale/base.py index 1c50ce58..c28d05d8 100644 --- a/tale/base.py +++ b/tale/base.py @@ -56,6 +56,7 @@ from . import player from .errors import ActionRefused, ParseError, LocationIntegrityError, TaleError, UnknownVerbException, NonSoulVerb from tale.races import UnarmedAttack +from tale.wearable import Wearable, WearLocation __all__ = ["MudObject", "Armour", 'Container', "Door", "Exit", "Item", "Living", "Stats", "Location", "Weapon", "Key", "Soul"] @@ -590,6 +591,7 @@ def __init__(self, name: str, wc: int = 0, title: str = "", *, descr: str = "", class Armour(Item): """ An item that can be worn by a Living (i.e. present in an armour itemslot) + Used by Circle Mud. Not related to Wearable at present. Not used by LlamaTale. """ pass @@ -964,6 +966,8 @@ def __init__(self, name: str, gender: str, *, race: str="human", self.following = None # type: Optional[Living] self.is_pet = False # set this to True if creature is/becomes someone's pet self.__wielding = None # type: Optional[Weapon] + self.__wearing = dict() # type: Dict[str, Wearable] + super().__init__(name, title=title, descr=descr, short_descr=short_descr) def init_gender(self, gender: str) -> None: @@ -1469,6 +1473,18 @@ def wielding(self, weapon: Optional[Weapon]) -> None: self.__wielding = weapon self.stats.wc = weapon.wc if self.__wielding else 0 + def set_wearable(self, wearable: Optional[Wearable], location: Optional[WearLocation]) -> None: + """ Wear an item if item is not None, else unwear location""" + if wearable: + loc = location if location else wearable.location + self.__wearing[loc] = wearable + elif location: + self.__wearing.pop(location, None) + + def get_wearable(self, location: WearLocation) -> Optional[Wearable]: + """Return the wearable item at the given location, or None if no item is worn there.""" + return self.__wearing.get(location) + class Container(Item): """ diff --git a/tale/cmds/normal.py b/tale/cmds/normal.py index 05544fbb..01de1779 100644 --- a/tale/cmds/normal.py +++ b/tale/cmds/normal.py @@ -22,6 +22,7 @@ from ..player import Player from ..story import GameMode from ..verbdefs import VERBS, ACTION_QUALIFIERS, BODY_PARTS, AGGRESSIVE_VERBS +from tale.wearable import WearLocation @cmd("inventory") @@ -1701,12 +1702,33 @@ def do_wield(player: Player, parsed: base.ParseResult, ctx: util.Context) -> Non except ValueError as x: raise ActionRefused(str(x)) result = player.locate_item(weapon, include_location=False) - if result: - player.wielding = result[0] - else: + if not result: raise ActionRefused("You don't have that weapon") + player.wielding = result[0] @cmd("unwield") def do_unwield(player: Player, parsed: base.ParseResult, ctx: util.Context) -> None: """Unwield a weapon.""" - player.wielding = None \ No newline at end of file + player.wielding = None + +@cmd("wear") +def do_wear(player: Player, parsed: base.ParseResult, ctx: util.Context) -> None: + """Wear an item.""" + if len(parsed.args) < 1: + raise ParseError("You need to specify the item to wear") + try: + item = str(parsed.args[0]) + except ValueError as x: + raise ActionRefused(str(x)) + if len(parsed.args) == 2: + try: + parsed_loc = str(parsed.args[1]) + location = WearLocation[parsed_loc.upper()] + except ValueError: + raise ActionRefused("Invalid location") + + + result = player.locate_item(item, include_location=False) + if not result: + raise ActionRefused("You don't have that item") + player.set_wearable(result[0], location=location) \ No newline at end of file diff --git a/tale/parse_utils.py b/tale/parse_utils.py index 7d6c5d44..11367b1f 100644 --- a/tale/parse_utils.py +++ b/tale/parse_utils.py @@ -23,7 +23,7 @@ def load_locations(json_file: dict): zone[json_file['name']] = locations for loc in json_file['rooms']: name = loc['name'] - locations[name] = Location(name, loc['descr']) + locations[name] = location_from_json(loc) loc_exits = loc['exits'] for loc_exit in loc_exits: temp_exits.setdefault(name,{})[loc_exit['name']] = loc_exit @@ -42,6 +42,13 @@ def load_locations(json_file: dict): return zone, exits +def location_from_json(json_object: dict): + return Location(name=json_object['name'], descr=json_object['descr']) + +def connect_location_to_exit(location_to: Location, location_from: Location, exit_from: Exit): + exit_from.bind(location_to) + exit_back = Exit(directions=location_from.name, short_desc=exit_from.short_desc, long_desc=exit_from.long_desc) # need exit descs + def load_items(json_file: dict, locations = {}): """ Loads and returns a dict of items from a supplied json dict @@ -145,4 +152,5 @@ def sanitize_json(result: str): # .replace('}}', '}') result = result.replace('\\"', '"').replace('"\\n"', '","').replace('\\n', '').replace('}\n{', '},{').replace('}{', '},{').replace('\\r', '').replace('\\t', '').replace('"{', '{').replace('}"', '}').replace('"\\', '"').replace('""', '"').replace('\\”', '"').replace('" "', '","').replace(':,',':') print('sanitized json: ' + result) - return result \ No newline at end of file + return result + diff --git a/tale/wearable.py b/tale/wearable.py new file mode 100644 index 00000000..7a82c11d --- /dev/null +++ b/tale/wearable.py @@ -0,0 +1,263 @@ +import enum +from tale.base import Item + +class WearLocation(enum.Enum): + FULL_BODY = 0 # robes etc, covers TORSO, ARMS, LEGS + HEAD = 1 + FACE = 2 + NECK = 3 + TORSO = 4 + ARMS = 5 + HANDS = 6 + LEGS = 7 + FEET = 8 + WAIST = 9 # belts etc + BACK = 10 # backpacks etc + UNDER_GARMENTS = 11 # underwear etc + + +wearables_fantasy = { + 'robe': { + 'type': WearLocation.FULL_BODY, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'cloak': { + 'type': WearLocation.BACK, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'tunic': { + 'type': WearLocation.TORSO, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'shirt': { + 'type': WearLocation.TORSO, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'pants': { + 'type': WearLocation.LEGS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'shoes': { + 'type': WearLocation.FEET, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'belt': { + 'type': WearLocation.WAIST, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'hat': { + 'type': WearLocation.HEAD, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'steel_helmet': { + 'type': WearLocation.HEAD, + 'weight': 3, + 'value': 10, + 'ac': 3, + }, + 'mail_coif': { + 'type': WearLocation.HEAD, + 'weight': 3, + 'value': 10, + 'ac': 2, + }, + 'breeches': { + 'type': WearLocation.LEGS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, +} + +wearables_modern = { + 't-shirt': { + 'type': WearLocation.TORSO, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'shirt': { + 'type': WearLocation.TORSO, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'jeans': { + 'type': WearLocation.LEGS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'sneakers': { + 'type': WearLocation.FEET, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'belt': { + 'type': WearLocation.WAIST, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'dress_shoes': { + 'type': WearLocation.FEET, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'hat': { + 'type': WearLocation.HEAD, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'dress': { + 'type': WearLocation.FULL_BODY, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'suit': { + 'type': WearLocation.FULL_BODY, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'jacket': { + 'type': WearLocation.TORSO, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'coat': { + 'type': WearLocation.TORSO, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'cap': { + 'type': WearLocation.HEAD, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'pants': { + 'type': WearLocation.LEGS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'shorts': { + 'type': WearLocation.LEGS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'boxers': { + 'type': WearLocation.UNDER_GARMENTS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'briefs': { + 'type': WearLocation.UNDER_GARMENTS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'bra': { + 'type': WearLocation.UNDER_GARMENTS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'socks': { + 'type': WearLocation.UNDER_GARMENTS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'panties': { + 'type': WearLocation.UNDER_GARMENTS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'thong': { + 'type': WearLocation.UNDER_GARMENTS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'stockings': { + 'type': WearLocation.UNDER_GARMENTS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'skirt': { + 'type': WearLocation.LEGS, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'dress_shirt': { + 'type': WearLocation.TORSO, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'blouse': { + 'type': WearLocation.TORSO, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'sweater': { + 'type': WearLocation.TORSO, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'coveralls': { + 'type': WearLocation.FULL_BODY, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, + 'top': { + 'type': WearLocation.TORSO, + 'weight': 1, + 'value': 10, + 'ac': 0, + }, +} + +female_clothing_modern = {'dress', 'dress_shirt', 'blouse', 'skirt', 'bra', 'panties', 'thong', 'stockings', 'top'} +male_clothing_modern = {'suit', 'boxers', 'briefs', 'shirt'} +neutral_clothing_modern = {'t-shirt', 'shirt', 'jeans', 'sneakers', 'belt', 'dress_shoes', 'hat', 'coveralls', 'sweater', 'socks', 'coat', 'jacket'} + +class Wearable(Item): + + def __init__(self, name: str, weight, value, ac, wearable_type): + super().__init__(name, descr=name, value=value) + self.ac = ac + self.weight = weight + self.type = wearable_type \ No newline at end of file From 9eb1ed7d3656b05cedf2be5d44e084ae6b56037c Mon Sep 17 00:00:00 2001 From: rickard Date: Sat, 26 Aug 2023 11:11:00 +0200 Subject: [PATCH 3/3] generating locations works --- llm_config.yaml | 6 +- stories/prancingllama/story.py | 16 ++++- stories/prancingllama/zones/prancingllama.py | 9 ++- stories/test_story/npcs/test_npcs.json | 12 ++++ stories/test_story/test_items.json | 27 +++++++ stories/test_story/test_locations.json | 15 ++++ stories/test_story/test_story_config.json | 33 +++++++++ stories/test_story/zones/test_locations.json | 15 ++++ tale/base.py | 23 ++++-- tale/driver.py | 14 +++- tale/driver_if.py | 1 + tale/json_story.py | 23 +++++- tale/llm_ext.py | 8 ++- tale/llm_utils.py | 43 ++++++------ tale/parse_utils.py | 70 +++++++++++++++++-- tale/story.py | 1 + tale/wearable.py | 14 ++-- tests/__init__.py | 1 + tests/files/__init__.py | 6 ++ tests/files/test_story/test_locations.json | 2 +- ..._load_json_story.py => test_json_story.py} | 8 +++ tests/test_parse_utils.py | 32 ++++++++- 22 files changed, 321 insertions(+), 58 deletions(-) create mode 100644 stories/test_story/npcs/test_npcs.json create mode 100644 stories/test_story/test_items.json create mode 100644 stories/test_story/test_locations.json create mode 100644 stories/test_story/test_story_config.json create mode 100644 stories/test_story/zones/test_locations.json create mode 100644 tests/files/__init__.py rename tests/{test_load_json_story.py => test_json_story.py} (65%) diff --git a/llm_config.yaml b/llm_config.yaml index d80ce369..06c19949 100644 --- a/llm_config.yaml +++ b/llm_config.yaml @@ -4,7 +4,7 @@ STREAM: False STREAM_ENDPOINT: "/api/extra/generate/stream" DATA_ENDPOINT: "/api/extra/generate/check" WORD_LIMIT: 200 -DEFAULT_BODY: '{"stop_sequence": "", "max_length":500, "max_context_length":4096, "temperature":1.0, "top_k":120, "top_a":0.0, "top_p":0.85, "typical_p":1.0, "tfs":1.0, "rep_pen":1.2, "rep_pen_range":256, "mirostat":2, "mirostat_tau":5.0, "mirostat_eta":0.1, "sampler_order":[6,0,1,3,4,2,5], "seed":-1}' +DEFAULT_BODY: '{"stop_sequence": "", "max_length":500, "max_context_length":4096, "temperature":1.0, "top_k":120, "top_a":0.0, "top_p":0.85, "typical_p":1.0, "tfs":1.0, "rep_pen":1.2, "rep_pen_range":256, "sampler_order":[6,0,1,3,4,2,5], "seed":-1}' ANALYSIS_BODY: '{"banned_tokens":"\n\n", "stop_sequence": "\n\n\n", "max_length":500, "max_context_length":4096, "temperature":0.15, "top_k":120, "top_a":0.0, "top_p":0.85, "typical_p":1.0, "tfs":1.0, "rep_pen":1.2, "rep_pen_range":256, "mirostat":2, "mirostat_tau":5.0, "mirostat_eta":0.1, "sampler_order":[6,0,1,3,4,2,5], "seed":-1}' MEMORY_SIZE: 512 PRE_PROMPT: 'Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.\n\n' @@ -13,7 +13,7 @@ ACTION_PROMPT: "[Story context: {story_context}]; [History: {history}]; The foll DIALOGUE_PROMPT: '[Story context: {story_context}]; [Location: {location}]; The following is a conversation between {character1} and {character2}; {character1}:{character1_description}; character2:{character2_description}; [Chat history: {previous_conversation}]\n\n [{character2}s sentiment towards {character1}: {sentiment}]. ### Instruction: Write a single response for {character2} in third person pov, using {character2} description.\n\n### Response:\n' ITEM_PROMPT: 'Items:[{items}];Characters:[{character1},{character2}] \n\n ### Instruction: Decide if an item was explicitly given, taken, dropped or put somewhere in the following text:[Text:{text}]. Insert your thoughts about whether an item was explicitly given, taken, put somewhere or dropped in "my thoughts", and the results in "item", "from" and "to", or make them empty if . Insert {character1}s sentiment towards {character2} in a single word in "sentiment assessment". Fill in the following JSON template: {{ "thoughts":"my thoughts", "result": {{ "item":"", "from":"", "to":""}}, {{"sentiment":"sentiment assessment"}} }} End of example. \n\n Write your response in valid JSON\n\n### Response:\n' COMBAT_PROMPT: 'Rewrite the following combat between user {attacker}, weapon:{attacker_weapon} and {victim}, weapon:{victim_weapon} into a vivid description in less than 300 words. Location: {location}, {location_description}. ### Instruction: Rewrite the following combat result in about 150 words, using the characters weapons . Combat Result: {attacker_msg} ### Response:\n\n' -CREATE_CHARACTER_PROMPT: '### Instruction: For a low-fantasy roleplaying game, create a diverse character with rich personality that can be interacted with using the story context and keywords. Do not mention height. Story context: {story_context}; keywords: {keywords}. Fill in this JSON template and write nothing else: {{"name":"", "description": "", "appearance": "", "personality": "", "money":(int), "level":"", "gender":"m/f/n", "age":(int), "race":""}} ### Response:\n' -CREATE_LOCATION_PROMPT: '[Story context: {story_context}]; Exit example: {"name" : "connected room", "short_desc":"description visible to use", "long_desc":"detailed description", "enter_msg":"message when using exit"}; Item example: {"name":"", "type":""}, type can be "Weapon", "Armor", "Other" or "Money"; Character example: {"name":"", "type":""}, type can be "friendly", "neutral", "hostile". Existing connected room: {exit_room}. ### Instruction: For a {story_type}, describe the following room: {room_name}. Add a description and include at least one exit leading to an additional room. Fill in this JSON template and do not write anything else: {"name":"", "description": "", "exits": [], "items":[], "npcs":[]}. Make sure the response is valid JSON. ### Response:\n' +CREATE_CHARACTER_PROMPT: '### Instruction: For a low-fantasy roleplaying game, create a diverse character with rich personality that can be interacted with using the story context and keywords. Do not mention height. Story context: {story_context}; keywords: {keywords}. Fill in this JSON template and write nothing else: {{"name":"", "description": "50 words", "appearance": "25 words", "personality": "50 words", "money":(int), "level":"", "gender":"m/f/n", "age":(int), "race":""}} ### Response:\n' +CREATE_LOCATION_PROMPT: '[Story context: {story_context}]; Exit example: {{"name" : "location name", "short_descr":"description visible to user", "enter_msg":"message when using exit"}}; Item example: {{"name":"", "type":""}}, type can be "Weapon", "Armor", "Other" or "Money"; Character example: {{"name":"", "type":""}}, type can be "friendly", "neutral", "hostile". Existing connected location: {exit_location}. ### Instruction: For a {story_type}, describe the following location: {location_name}. Add a description, and one to three additional exits leading to new locations. Fill in this JSON template and do not write anything else: {{"description": "25 words", "exits":[], "items":[], "npcs":[]}}. Write the response in valid JSON. ### Response:\n\n' diff --git a/stories/prancingllama/story.py b/stories/prancingllama/story.py index 20714bdb..3605f81e 100644 --- a/stories/prancingllama/story.py +++ b/stories/prancingllama/story.py @@ -3,13 +3,15 @@ from typing import Optional, Generator import tale +from tale.base import Location from tale.driver import Driver +from tale.llm_ext import DynamicStory from tale.main import run_from_cmdline from tale.player import Player, PlayerConnection from tale.charbuilder import PlayerNaming from tale.story import * -class Story(StoryBase): +class Story(DynamicStory): config = StoryConfig() config.name = "The Prancing Llama" @@ -28,10 +30,14 @@ class Story(StoryBase): config.startlocation_wizard = "prancingllama.entrance" config.zones = ["prancingllama"] config.context = "The Prancing Llama is the final outpost high up in a cold, craggy mountain range. It's frequented by adventurers and those seeking to avoid attention." - + config.type = "A low level fantasy adventure with focus of character building and interaction." + + def init(self, driver: Driver) -> None: """Called by the game driver when it is done with its initial initialization.""" self.driver = driver + self._dynamic_locations = dict() # type: dict(str, []) + self._dynamic_locations["prancingllama"] = [] def init_player(self, player: Player) -> None: """ @@ -75,6 +81,12 @@ def goodbye(self, player: Player) -> None: player.tell("Goodbye, %s. Please come back again soon." % player.title) player.tell("\n") + def add_location(self, location: Location, zone: str = '') -> None: + """ Add a location to the story. + If zone is specified, add to that zone, otherwise add to first zone. + """ + self._dynamic_locations["prancingllama"].append(location) + if __name__ == "__main__": # story is invoked as a script, start it in the Tale Driver. diff --git a/stories/prancingllama/zones/prancingllama.py b/stories/prancingllama/zones/prancingllama.py index c5377555..6960b2a3 100644 --- a/stories/prancingllama/zones/prancingllama.py +++ b/stories/prancingllama/zones/prancingllama.py @@ -28,7 +28,8 @@ def spawn_rat(self, ctx: Context) -> None: kitchen = Location("Kitchen", "The walls are lined with barrels and smoke is surrounding the steaming pots on the fires.") hearth = Location("Hearth", "A place for newly arrived visitors to unthaw their frozen limbs.") entrance = Location("Entrance", "A room full of furry and snow-covered coats. Loud voices and roars of laughter can be heard from the main hall.") -outside = Location("Outside", "A snow-storm is raging across the craggy landscape outside, it's dark, and noone to be seen. It's better to stay indoors") +outside = Location("Outside") +outside.built = False # Specify this to be generated by the LLM cellar = Cellar("Cellar", "A dark and damp place, with cob-webs in the corners. Filled with barrels and boxes of various kind.") @@ -42,7 +43,9 @@ def spawn_rat(self, ctx: Context) -> None: Exit.connect(main_hall, ["entrance", "south"], "", "The entrance to the building is to the south.", entrance, ["main hall", "north"], "There's a warm glow and loud, noisy conversations coming through a doorway to the north", "") -Exit.connect(entrance, ["outside", "south"], "A biting wind reaches you through the door to the south.", "", outside, ["entrance", "north"], "There's shelter from the cold wind through a door to the north.", "") +entrance.add_exits([Exit(["outside", "south"], short_descr="A biting wind reaches you through the door to the south.", target_location=outside)]) + +outside.add_exits([Exit(["entrance", "north"], short_descr="The door to the north leads inside The Prancing Llama.", target_location=entrance)]) main_hall.init_inventory([shanda, elid_gald]) @@ -86,7 +89,7 @@ def _generate_character(): return True return False -# 10 attempts to generate 2 characters +# 5 attempts to generate 2 characters generated = 0 for i in range(5): if _generate_character(): diff --git a/stories/test_story/npcs/test_npcs.json b/stories/test_story/npcs/test_npcs.json new file mode 100644 index 00000000..71c2e5ab --- /dev/null +++ b/stories/test_story/npcs/test_npcs.json @@ -0,0 +1,12 @@ +[ + { + "name":"Kobbo", + "gender":"m", + "race":"kobold", + "type":"Living", + "title":"Kobbo the King", + "descr":"Kobbo has a grace only royal kobolds posses", + "short_descr":"A kobold", + "location":"Cave.Royal Grotto" + } +] diff --git a/stories/test_story/test_items.json b/stories/test_story/test_items.json new file mode 100644 index 00000000..8315642d --- /dev/null +++ b/stories/test_story/test_items.json @@ -0,0 +1,27 @@ +[ + { + "name":"Box 1", + "title":"Box 1", + "descr":"A small box with intricate bejewelled patterns", + "short_descr": "A small bejewelled box", + "type":"Boxlike", + "location":"Cave.Cave entrance" + }, + { + "name":"Royal Sceptre", + "title":"Royal Sceptre", + "descr":"This stick is Kobbo the Kings royal sceptre. Looks good for walloping someone with", + "short_descr":"A gnarly looking stick", + "type":"Weapon", + "location":"Cave.Royal Grotto" + }, + { + "name":"Hoodie", + "title":"Grey Hoodie", + "descr":"A brand new, grey hoodie with a logo of a common brand", + "short_descr": "A grey hoodie", + "type":"Wearable", + "location":"Cave.Cave entrance" + } + +] diff --git a/stories/test_story/test_locations.json b/stories/test_story/test_locations.json new file mode 100644 index 00000000..691b074b --- /dev/null +++ b/stories/test_story/test_locations.json @@ -0,0 +1,15 @@ +{ + "name":"Cave", + "rooms":[ + { + "name": "Cave entrance", + "descr": "This cave entrance gives shelter from the wind and rain.", + "exits": [{"name" : "Royal Grotto", "short_desc":"Exit to Grotto", "long_desc":"There's an opening that leads deeper into the cave'", "enter_msg":""}] + }, + { + "name": "Royal Grotto", + "descr": "This is Kobbo the Kings throne room. It's a dark, damp place with a log in one end", + "exits": [{"name" : "Cave entrance", "short_desc":"exit to Cave entrance", "long_desc":"", "enter_msg":""}] + } + ] +} diff --git a/stories/test_story/test_story_config.json b/stories/test_story/test_story_config.json new file mode 100644 index 00000000..90836f0a --- /dev/null +++ b/stories/test_story/test_story_config.json @@ -0,0 +1,33 @@ +{ + "name":"Test Story Config 1", + "type":"StoryConfig", + "author":"test author", + "author_address":"" , + "version":"1.10" , + "requires_tale":"4.0" , + "supported_modes":["IF"] , + "player_name":"" , + "player_gender":"" , + "player_race":"human" , + "player_money":0.0 , + "playable_races":[] , + "money_type":"NOTHING" , + "server_tick_method":"COMMAND" , + "server_tick_time":5.0 , + "gametime_to_realtime":1 , + "max_wait_hours":2 , + "display_gametime":false , + "display_race":false , + "epoch":null , + "startlocation_player":"Cave.Cave entrance" , + "startlocation_wizard":"Cave.Cave entrance" , + "savegames_enabled":true , + "show_exits_in_look":true , + "license_file":"", + "mud_host":"", + "mud_port":0, + "zones":["test_locations"], + "npcs":"test_npcs", + "items":"test_items", + "server_mode":"IF" +} diff --git a/stories/test_story/zones/test_locations.json b/stories/test_story/zones/test_locations.json new file mode 100644 index 00000000..691b074b --- /dev/null +++ b/stories/test_story/zones/test_locations.json @@ -0,0 +1,15 @@ +{ + "name":"Cave", + "rooms":[ + { + "name": "Cave entrance", + "descr": "This cave entrance gives shelter from the wind and rain.", + "exits": [{"name" : "Royal Grotto", "short_desc":"Exit to Grotto", "long_desc":"There's an opening that leads deeper into the cave'", "enter_msg":""}] + }, + { + "name": "Royal Grotto", + "descr": "This is Kobbo the Kings throne room. It's a dark, damp place with a log in one end", + "exits": [{"name" : "Cave entrance", "short_desc":"exit to Cave entrance", "long_desc":"", "enter_msg":""}] + } + ] +} diff --git a/tale/base.py b/tale/base.py index c28d05d8..21c40301 100644 --- a/tale/base.py +++ b/tale/base.py @@ -14,6 +14,8 @@ | | | +-- Weapon | +-- Armour + +-- Wearable + +-- Remains | +-- Container | +-- Key | @@ -53,10 +55,9 @@ from . import story from . import verbdefs from . import combat -from . import player from .errors import ActionRefused, ParseError, LocationIntegrityError, TaleError, UnknownVerbException, NonSoulVerb from tale.races import UnarmedAttack -from tale.wearable import Wearable, WearLocation +from . import wearable __all__ = ["MudObject", "Armour", 'Container', "Door", "Exit", "Item", "Living", "Stats", "Location", "Weapon", "Key", "Soul"] @@ -596,6 +597,14 @@ class Armour(Item): pass +class Wearable(Item): + + def __init__(self, name: str, weight, value, ac, wearable_type): + super(Wearable).__init__(name, descr=name, value=value) + self.ac = ac + self.weight = weight + self.type = wearable_type + class Location(MudObject): """ A location in the mud world. Livings and Items are in it. @@ -966,7 +975,7 @@ def __init__(self, name: str, gender: str, *, race: str="human", self.following = None # type: Optional[Living] self.is_pet = False # set this to True if creature is/becomes someone's pet self.__wielding = None # type: Optional[Weapon] - self.__wearing = dict() # type: Dict[str, Wearable] + self.__wearing = dict() # type: Dict[str, wearable.Wearable] super().__init__(name, title=title, descr=descr, short_descr=short_descr) @@ -1353,9 +1362,9 @@ def start_attack(self, victim: 'Living') -> None: victim.tell(victim_msg, evoke=True, max_length=False) - if isinstance(self, player.Player): + if isinstance(self, 'tale.player.Player'): attacker_name += "as 'You'" - if isinstance(victim, player.Player): + if isinstance(victim, 'tale.player.Player'): victim_name += "as 'You'" combat_prompt = mud_context.driver.llm_util.combat_prompt.format(attacker=attacker_name, @@ -1473,7 +1482,7 @@ def wielding(self, weapon: Optional[Weapon]) -> None: self.__wielding = weapon self.stats.wc = weapon.wc if self.__wielding else 0 - def set_wearable(self, wearable: Optional[Wearable], location: Optional[WearLocation]) -> None: + def set_wearable(self, wearable: Optional[Wearable], location: Optional[wearable.WearLocation]) -> None: """ Wear an item if item is not None, else unwear location""" if wearable: loc = location if location else wearable.location @@ -1481,7 +1490,7 @@ def set_wearable(self, wearable: Optional[Wearable], location: Optional[WearLoca elif location: self.__wearing.pop(location, None) - def get_wearable(self, location: WearLocation) -> Optional[Wearable]: + def get_wearable(self, location: wearable.WearLocation) -> Optional[Wearable]: """Return the wearable item at the given location, or None if no item is worn there.""" return self.__wearing.get(location) diff --git a/tale/driver.py b/tale/driver.py index aa645605..9d1df993 100644 --- a/tale/driver.py +++ b/tale/driver.py @@ -17,6 +17,7 @@ import sys import threading import time +import typing from functools import total_ordering from types import ModuleType from typing import Sequence, Union, Tuple, Any, Dict, Callable, Iterable, Generator, Set, List, MutableSequence, Optional @@ -25,13 +26,12 @@ from . import __version__ as tale_version_str, _check_required_libraries from . import mud_context, errors, util, cmds, player, pubsub, charbuilder, lang, verbdefs, vfs, base -from .llm_utils import LlmUtil from .story import TickMethod, GameMode, MoneyType, StoryBase from .tio import DEFAULT_SCREEN_WIDTH from .races import playable_races from .errors import StoryCompleted from tale.load_character import CharacterLoader, CharacterV2 -from tale.llm_ext import LivingNpc +from tale.llm_ext import LivingNpc, DynamicStory from tale.llm_utils import LlmUtil @@ -606,6 +606,16 @@ def _process_player_command(self, cmd: str, conn: player.PlayerConnection) -> No def go_through_exit(self, player: player.Player, direction: str, evoke: bool=True) -> None: xt = player.location.exits[direction] xt.allow_passage(player) + if not xt.target.built: + # generate the location if it's not built yet. retry 5 times. + for i in range(5): + new_locations = self.llm_util.build_location(location=xt.target, exit_location=player.location) + if new_locations: + break + if not new_locations: + raise AssertionError("failed to build location: " + xt.target.name + ". You can try entering again.") + for location in new_locations: + typing.cast(DynamicStory, self.story).add_location(location) if xt.enter_msg: player.tell(xt.enter_msg, end=True, evoke=evoke, max_length=True) player.tell("\n") diff --git a/tale/driver_if.py b/tale/driver_if.py index 6ba11ee7..5b70f125 100644 --- a/tale/driver_if.py +++ b/tale/driver_if.py @@ -205,6 +205,7 @@ def _login_dialog_if(self, conn: PlayerConnection) -> Generator: player.tell("\n") prompt = self.story.welcome(player) + self.llm_util.story_type = self.story.config.type self.llm_util.story_background = self.story.config.context if prompt: diff --git a/tale/json_story.py b/tale/json_story.py index ab5daf3b..2edfed8f 100644 --- a/tale/json_story.py +++ b/tale/json_story.py @@ -14,7 +14,7 @@ def __init__(self, path: str, config: StoryConfig): for zone in self.config.zones: locs, exits = parse_utils.load_locations(parse_utils.load_json(self.path +'zones/'+zone + '.json')) self._locations = locs - self.zones = locs + self._zones = locs self._npcs = parse_utils.load_npcs(parse_utils.load_json(self.path +'npcs/'+self.config.npcs + '.json'), self._locations) self._items = parse_utils.load_items(parse_utils.load_json(self.path + self.config.items + '.json'), self._locations) @@ -35,8 +35,27 @@ def goodbye(self, player: Player) -> None: player.tell("Thanks for trying out Tale!") def get_location(self, zone: str, name: str) -> Location: - return self._locations[zone][name] + """ Find a location by name in a zone.""" + return self._zones[zone][name] + def find_location(self, name: str) -> Location: + """ Find a location by name in any zone.""" + for zone in self._zones: + for loc in zone.values(): + if loc.name == name: + return loc + + def add_location(self, location: Location, zone: str = '') -> None: + """ Add a location to the story. + If zone is specified, add to that zone, otherwise add to first zone. + """ + if zone: + self._zones[zone][location.name] = location + return + for zone in self._zones: + self._zones[zone][location.name] = location + break + def get_npc(self, npc: str) -> Living: return self._npcs[npc] diff --git a/tale/llm_ext.py b/tale/llm_ext.py index e0ec8357..c6791b66 100644 --- a/tale/llm_ext.py +++ b/tale/llm_ext.py @@ -1,7 +1,8 @@ from tale import mud_context -from tale.base import Living, ParseResult +from tale.base import Living, Location, ParseResult from tale.errors import TaleError from tale.player import Player +from tale.story import StoryBase class LivingNpc(Living): """An NPC with extra fields to define personality and help LLM generate dialogue""" @@ -110,3 +111,8 @@ def character_card(self) -> str: description=self.description, occupation=self.occupation, items=','.join(items)) + +class DynamicStory(StoryBase): + + def add_location(self, location: Location, zone: str = '') -> None: + pass diff --git a/tale/llm_utils.py b/tale/llm_utils.py index 5924fe66..eed5c3c4 100644 --- a/tale/llm_utils.py +++ b/tale/llm_utils.py @@ -33,7 +33,8 @@ def __init__(self): self.location_prompt = config_file['CREATE_LOCATION_PROMPT'] self.item_prompt = config_file['ITEM_PROMPT'] self.word_limit = config_file['WORD_LIMIT'] - self._story_background = '' + self.story_background = '' + self.story_type = '' self.io_util = IoUtil() self.stream = config_file['STREAM'] self.connection = None @@ -48,7 +49,7 @@ def evoke(self, player_io: TextBuffer, message: str, max_length : bool=False, ro amount = 25 #int(len(trimmed_message) / 2) prompt = self.pre_prompt prompt += base_prompt.format( - story_context=self._story_background, + story_context=self.story_background, history=rolling_prompt if not skip_history or alt_prompt else '', max_words=self.word_limit if not max_length else amount, input_text=str(trimmed_message)) @@ -78,7 +79,7 @@ def generate_dialogue(self, conversation: str, max_length : bool=False): prompt = self.pre_prompt prompt += self.dialogue_prompt.format( - story_context=self._story_background, + story_context=self.story_background, location=location_description, previous_conversation=conversation, character2_description=character_card, @@ -157,7 +158,9 @@ def generate_character(self, story_context: str = '', keywords: list = []): keywords=', '.join(keywords)) request_body = self.default_body request_body['stop_sequence'] = ['\n\n'] # to avoid text after the character card - request_body['temperature'] = 1.0 + request_body['temperature'] = 0.7 + request_body['top_p'] = 0.92 + request_body['rep_pen'] = 1.0 request_body['banned_tokens'] = ['```'] request_body['prompt'] = prompt result = self.io_util.synchronous_request(self.url + self.endpoint, request_body) @@ -175,32 +178,30 @@ def generate_character(self, story_context: str = '', keywords: list = []): def build_location(self, location: Location, exit_location: Location): """ Generate a location based on the current story context""" prompt = self.location_prompt.format( - story_type=self._story_type, - story_context=self._story_background, + story_type=self.story_type, + story_context=self.story_background, exit_location=exit_location.name, location_name=location.name) request_body = self.default_body request_body['stop_sequence'] = ['\n\n'] - request_body['temperature'] = 1.0 + request_body['temperature'] = 0.7 + request_body['top_p'] = 0.92 + request_body['top_k'] = 0 + request_body['rep_pen'] = 1.1 request_body['banned_tokens'] = ['```'] request_body['prompt'] = prompt result = self.io_util.synchronous_request(self.url + self.endpoint, request_body) try: json_result = json.loads(parse_utils.sanitize_json(result)) - #should be a location in json format + # should be a location in json format, including exits, items and npcs + location.description = json_result['description'] + # handle items + # handle characters + new_locations, exits = parse_utils.parse_generated_exits(json_result, exit_location.name, location) + location.built = True + location.add_exits(exits) + return new_locations except JSONDecodeError as exc: print(exc) return None - try: - return location.from_json(json_result) - except: - print(f'Exception while parsing location {json_result}') - return None - - @property - def story_background(self) -> str: - return self._story_background - - @story_background.setter - def story_background(self, value: str) -> None: - self._story_background = value + \ No newline at end of file diff --git a/tale/parse_utils.py b/tale/parse_utils.py index 11367b1f..5c5799ba 100644 --- a/tale/parse_utils.py +++ b/tale/parse_utils.py @@ -1,7 +1,7 @@ +from typing import Union from tale.base import Location, Exit, Item, Living from tale.items.basic import Money, Note from tale.story import GameMode, MoneyType, TickMethod, StoryConfig -import collections import json import re import sys @@ -45,10 +45,6 @@ def load_locations(json_file: dict): def location_from_json(json_object: dict): return Location(name=json_object['name'], descr=json_object['descr']) -def connect_location_to_exit(location_to: Location, location_from: Location, exit_from: Exit): - exit_from.bind(location_to) - exit_back = Exit(directions=location_from.name, short_desc=exit_from.short_desc, long_desc=exit_from.long_desc) # need exit descs - def load_items(json_file: dict, locations = {}): """ Loads and returns a dict of items from a supplied json dict @@ -154,3 +150,67 @@ def sanitize_json(result: str): print('sanitized json: ' + result) return result +# These are related to LLM generated content + +def connect_location_to_exit(location_to: Location, location_from: Location, exit_from: Exit): + """ Creates an exit back from location_to to location_from + Will try to use the opposite direction, if possible""" + try: + exit_back = location_to.exits[location_from.name] + except KeyError: + directions = [location_from.name] + for dir in [exit_from.name, exit_from.aliases]: + opposite = opposite_direction(dir) + if opposite != None: + directions.append(opposite) + break + + exit_back = Exit(directions=directions, + target_location=location_from, + short_descr=f'You can see {location_from.name}') # need exit descs + location_to.add_exits([exit_back]) + +def opposite_direction(direction: str): + """ Returns the opposite direction of the supplied direction. Thanks copilot!""" + if direction == 'north': + return 'south' + if direction == 'south': + return 'north' + if direction == 'east': + return 'west' + if direction == 'west': + return 'east' + if direction == 'up': + return 'down' + if direction == 'down': + return 'up' + if direction == 'in': + return 'out' + if direction == 'out': + return 'in' + return None + +def parse_generated_exits(json_result: dict, exit_location_name: str, location: Location): + """ + Parses a json dict for new locations and exits + Returns list of new locations and exits + """ + new_locations = [] + exits = [] + for exit in json_result['exits']: + if exit['name'] != exit_location_name: + # create location + new_location = Location(exit['name']) + new_location.built = False + exit_back = Exit(directions=location.name, + target_location=location, + short_descr=f'You can see {location.name}') # need exit descs + new_location.add_exits([exit_back]) + exit_to = Exit(directions=new_location.name, + target_location=new_location, + short_descr=exit.get('short_descr', ''), + enter_msg=exit.get('enter_msg', '')) + exits.append(exit_to) + new_locations.append(new_location) + return new_locations, exits + diff --git a/tale/story.py b/tale/story.py index 95c07048..447c23e9 100644 --- a/tale/story.py +++ b/tale/story.py @@ -73,6 +73,7 @@ def __init__(self) -> None: self.items = "" # items to populate the world with. only used by json loading self.npcs = "" # npcs to populate the world with. only used by json loading self.context = "" # context to giving background for the story. + self.type = "" # brief description of the setting and type of story, for LLM context def __eq__(self, other: Any) -> bool: return isinstance(other, StoryConfig) and vars(self) == vars(other) diff --git a/tale/wearable.py b/tale/wearable.py index 7a82c11d..d26bbe37 100644 --- a/tale/wearable.py +++ b/tale/wearable.py @@ -1,5 +1,5 @@ import enum -from tale.base import Item +import tale.base as base class WearLocation(enum.Enum): FULL_BODY = 0 # robes etc, covers TORSO, ARMS, LEGS @@ -15,7 +15,7 @@ class WearLocation(enum.Enum): BACK = 10 # backpacks etc UNDER_GARMENTS = 11 # underwear etc - +# Mostly 'copilot' generated wearable types wearables_fantasy = { 'robe': { 'type': WearLocation.FULL_BODY, @@ -85,6 +85,7 @@ class WearLocation(enum.Enum): }, } +# Mostly 'copilot' generated wearable types wearables_modern = { 't-shirt': { 'type': WearLocation.TORSO, @@ -250,14 +251,7 @@ class WearLocation(enum.Enum): }, } +# Disclaimer: Not to limit the player, but to give the generator some hints female_clothing_modern = {'dress', 'dress_shirt', 'blouse', 'skirt', 'bra', 'panties', 'thong', 'stockings', 'top'} male_clothing_modern = {'suit', 'boxers', 'briefs', 'shirt'} neutral_clothing_modern = {'t-shirt', 'shirt', 'jeans', 'sneakers', 'belt', 'dress_shoes', 'hat', 'coveralls', 'sweater', 'socks', 'coat', 'jacket'} - -class Wearable(Item): - - def __init__(self, name: str, weight, value, ac, wearable_type): - super().__init__(name, descr=name, value=value) - self.ac = ac - self.weight = weight - self.type = wearable_type \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index 8f48570b..5923d63c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,3 +4,4 @@ 'Tale' mud driver, mudlib and interactive fiction framework Copyright by Irmen de Jong (irmen@razorvine.net) """ + diff --git a/tests/files/__init__.py b/tests/files/__init__.py new file mode 100644 index 00000000..8f48570b --- /dev/null +++ b/tests/files/__init__.py @@ -0,0 +1,6 @@ +""" +Unit test suite. + +'Tale' mud driver, mudlib and interactive fiction framework +Copyright by Irmen de Jong (irmen@razorvine.net) +""" diff --git a/tests/files/test_story/test_locations.json b/tests/files/test_story/test_locations.json index 691b074b..135f05ca 100644 --- a/tests/files/test_story/test_locations.json +++ b/tests/files/test_story/test_locations.json @@ -4,7 +4,7 @@ { "name": "Cave entrance", "descr": "This cave entrance gives shelter from the wind and rain.", - "exits": [{"name" : "Royal Grotto", "short_desc":"Exit to Grotto", "long_desc":"There's an opening that leads deeper into the cave'", "enter_msg":""}] + "exits": [{"name" : "Royal Grotto", "short_desc":"Exit to Grotto", "long_desc":"There's an opening that leads deeper into the cave'", "enter_msg":"You enter the small crevice"}] }, { "name": "Royal Grotto", diff --git a/tests/test_load_json_story.py b/tests/test_json_story.py similarity index 65% rename from tests/test_load_json_story.py rename to tests/test_json_story.py index bf3f68cd..7f2af747 100644 --- a/tests/test_load_json_story.py +++ b/tests/test_json_story.py @@ -1,6 +1,7 @@ import tale.parse_utils as parse_utils from tale import mud_context +from tale.base import Location from tale.driver_if import IFDriver from tests.files.test_story.story import Story @@ -16,3 +17,10 @@ def test_load_story(self): assert(story.get_npc('Kobbo')) assert(story.get_npc('Kobbo').location.name == 'Royal Grotto') assert(story.get_item('Hoodie').location.name == 'Cave entrance') + + def test_add_location(self): + story = Story('tests/files/test_story/', parse_utils.load_story_config(parse_utils.load_json('tests/files/test_story/test_story_config.json'))) + mud_context.driver = IFDriver() + story.init(mud_context.driver) + new_location = Location('New Location', 'New Location') + story.add_location(new_location) diff --git a/tests/test_parse_utils.py b/tests/test_parse_utils.py index fbb2e654..2315277e 100644 --- a/tests/test_parse_utils.py +++ b/tests/test_parse_utils.py @@ -1,6 +1,6 @@ import pytest from tale import mud_context, util -from tale.base import Location +from tale.base import Exit, Location from tale.driver_if import IFDriver from tale.story import GameMode, MoneyType import tale.parse_utils as parse_utils @@ -58,4 +58,34 @@ def test_load_story_config(self): assert(config.supported_modes == {GameMode.IF}) assert(config.zones == ["test zone"]) + def test_connect_location_to_exit(self): + """ This simulates a room having been generated before""" + + cave_entrance = Location('Cave entrance', 'A dark cave entrance') + new_location = Location('Royal grotto', 'A small grotto, fit for a kobold king') + exit_to = Exit(directions=['north', 'Royal grotto'], target_location=new_location, short_descr='There\'s an opening that leads deeper into the cave', enter_msg='You enter the small crevice') + cave_entrance.add_exits([exit_to]) + # the room has now been 'built' and is being added to story + parse_utils.connect_location_to_exit(new_location, cave_entrance, exit_to) + assert(len(cave_entrance.exits) == 2) + assert(cave_entrance.exits['north'].target == new_location) + assert(cave_entrance.exits['Royal grotto'].target == new_location) + assert(len(new_location.exits) == 2) + + assert(new_location.exits['south'].target == cave_entrance) + assert(new_location.exits['cave entrance'].target == cave_entrance) + assert(new_location.exits['cave entrance'].short_description == f'You can see {cave_entrance.name}') + + def test_opposite_direction(self): + assert(parse_utils.opposite_direction('north') == 'south') + assert(parse_utils.opposite_direction('south') == 'north') + assert(parse_utils.opposite_direction('east') == 'west') + assert(parse_utils.opposite_direction('west') == 'east') + assert(parse_utils.opposite_direction('up') == 'down') + assert(parse_utils.opposite_direction('down') == 'up') + assert(parse_utils.opposite_direction('in') == 'out') + assert(parse_utils.opposite_direction('out') == 'in') + assert(parse_utils.opposite_direction('hubwards') == None) + +