Skip to content

Commit f8e4950

Browse files
authored
Merge pull request #2 from neph1/give_and_json
LLM decision making (experimental)
2 parents f312559 + 8b83643 commit f8e4950

34 files changed

+1168
-121
lines changed

stories/prancingllama/npcs/npcs.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from tale.base import Item, Living, ParseResult
55
from tale.errors import ParseError, ActionRefused
6+
from tale.lang import capital
67
from tale.llm_ext import LivingNpc
78
from tale.player import Player
89
from tale.util import call_periodically, Context
@@ -48,6 +49,10 @@ def do_random_move(self, ctx: Context) -> None:
4849
if direction:
4950
self.move(direction.target, self, direction_names=direction.names)
5051

52+
@call_periodically(30, 60)
53+
def do_pick_up_dishes(self, ctx: Context) -> None:
54+
self.location.tell("%s wipes a table and picks up dishes." % capital(self.title), evoke=False)
55+
5156
class Patron(LivingNpc):
5257

5358
def __init__(self, name: str, gender: str, *,
@@ -118,4 +123,7 @@ def do_idle_action(self, ctx: Context) -> None:
118123
urta = InnKeeper("Urta", "f", age=44, descr="A gruff, curvy woman with a long brown coat and bushy hair that reaches her waist. When not serving, she keeps polishing jugs with a dirty rag.", personality="She's the owner of The Prancing Llama, and of few words. But the words she speak are kind. She knows a little about all the patrons in her establishment.", short_descr="A curvy woman with long brown coat standing behind the bar.")
119124
urta.aliases = {"bartender", "inn keeper", "curvy woman"}
120125

126+
brim = Maid("Brim", "f", age=22, descr="A timid girl with long dark blonde hair in a braid down her back. She carries trays and dishes back and forth.", personality="She's shy and rarely looks anyone in the eye. When she speaks, it is merely a whisper. She traveled up the mountain looking for work, and ended up serving at the Inn. She dreams of big adventures.", short_descr="A timid maid with a braid wearing a tunic and dirty apron.")
127+
brim.aliases = {"maid", "timid girl", "serving girl"}
128+
121129

stories/prancingllama/story.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class Story(StoryBase):
1818
config.version = tale.__version__
1919
config.supported_modes = {GameMode.IF, GameMode.MUD}
2020
config.player_money = 10.5
21-
config.playable_races = {"human", "elf", "dwarf", "hobbit"}
21+
config.playable_races = {"human"}
2222
config.money_type = MoneyType.FANTASY
2323
config.server_tick_method = TickMethod.TIMER
2424
config.server_tick_time = 0.5

stories/prancingllama/zones/prancingllama.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,13 @@ def spawn_rat(self, ctx: Context) -> None:
4545

4646
bar.init_inventory([urta, norhardt])
4747

48-
hearth.init_inventory([count_karta])
48+
hearth.init_inventory([count_karta, brim])
4949

5050
drink = Item("ale", "jug of ale", descr="Looks and smells like strong ale.")
5151

5252
urta.init_inventory([drink])
5353

54-
old_map = Item("map", "old map.", descr="It looks like a map, and a cave is marked on it.")
54+
old_map = Item("map", "old map", descr="It looks like a map, and a cave is marked on it.")
5555

5656
norhardt.init_inventory([old_map])
5757

tale/base.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,8 @@ def tell(self, room_msg: str, exclude_living: 'Living'=None, specific_targets: S
642642
that based on this message string. That will make it quite hard because you need to
643643
parse the string again to figure out what happened... Use handle_verb / notify_action instead.
644644
"""
645+
#alt_prompt ="### Instruction: Location: [" + str(self.look(short=True)) + "]. Rewrite the following text in your own words using vivid language, use 'location' for context. Text:\n\n [{input_text}] \n\nEnd of text.\n\n### Response:\n"
646+
645647
targets = specific_targets or set()
646648
assert isinstance(targets, (frozenset, set, list, tuple))
647649
assert exclude_living is None or isinstance(exclude_living, Living)
@@ -669,7 +671,7 @@ def message_nearby_locations(self, message: str) -> None:
669671
if exit.target in yelled_locations:
670672
continue # skip double locations (possible because there can be multiple exits to the same location)
671673
if exit.target is not self:
672-
exit.target.tell(message, evoke=True, max_length=True)
674+
exit.target.tell(message, evoke=False, max_length=True)
673675
yelled_locations.add(exit.target)
674676
for direction, return_exit in exit.target.exits.items():
675677
if return_exit.target is self:
@@ -684,7 +686,7 @@ def message_nearby_locations(self, message: str) -> None:
684686
direction = "below"
685687
else:
686688
continue # no direction description possible for this exit
687-
exit.target.tell("The sound is coming from %s." % direction, evoke=True, max_length=True)
689+
exit.target.tell("The sound is coming from %s." % direction, evoke=False, max_length=True)
688690
break
689691

690692
def nearby(self, no_traps: bool=True) -> Iterable['Location']:
@@ -1034,7 +1036,7 @@ def wiz_clone(self, actor: 'Living', make_clone: bool=True) -> 'Living':
10341036
actor.tell("Cloned into: " + repr(duplicate) + " (spawned in current location)")
10351037
actor.tell_others("{Actor} summons %s..." % lang.a(duplicate.title))
10361038
actor.location.insert(duplicate, actor)
1037-
actor.location.tell("%s appears." % lang.capital(duplicate.title), evoke=True, max_length=True)
1039+
actor.location.tell("%s appears." % lang.capital(duplicate.title), evoke=False, max_length=True)
10381040
return duplicate
10391041

10401042
@util.authorized("wizard")
@@ -1274,7 +1276,7 @@ def display_direction(directions: Sequence[str]) -> str:
12741276
message = "%s leaves %s." % (lang.capital(self.title), direction_txt)
12751277
else:
12761278
message = "%s leaves." % lang.capital(self.title)
1277-
original_location.tell(message, exclude_living=self, evoke=True, max_length=True)
1279+
original_location.tell(message, exclude_living=self, evoke=False, max_length=True)
12781280
# queue event
12791281
if is_player:
12801282
pending_actions.send(lambda who=self, where=target: original_location.notify_player_left(who, where))
@@ -1283,7 +1285,7 @@ def display_direction(directions: Sequence[str]) -> str:
12831285
else:
12841286
target.insert(self, actor)
12851287
if not silent:
1286-
target.tell(f"{lang.capital(self.title)} arrives from {original_location}." , exclude_living=self, evoke=True, max_length=True)
1288+
target.tell(f"{lang.capital(self.title)} arrives from {original_location}." , exclude_living=self, evoke=False, max_length=True)
12871289
# queue event
12881290
if is_player:
12891291
pending_actions.send(lambda who=self, where=original_location: target.notify_player_arrived(who, where))
@@ -1340,6 +1342,7 @@ def start_attack(self, victim: 'Living') -> None:
13401342
victim_msg = "%s attacks you. %s" % (name, result)
13411343
attacker_msg = "You attack %s! %s" % (victim.title, result)
13421344
victim.tell(victim_msg, evoke=True, max_length=False)
1345+
# TODO: try to get from config file instead
13431346
combat_prompt = f'### Instruction: Rewrite the following combat between user {name} and {victim.title} and result into a vivid description in less than 300 words. Location: {self.location}, {self.location.short_description}. Write one to two paragraphs, ending in either death, or a stalemate. Combat Result: {attacker_msg} ### Response:\n\n'
13441347
victim.location.tell(room_msg, exclude_living=victim, specific_targets={self}, specific_target_msg=attacker_msg, evoke=True, max_length=False, alt_prompt=combat_prompt)
13451348
if dead:
@@ -1682,7 +1685,7 @@ def open(self, actor: Living, item: Item=None) -> None:
16821685
actor.tell_others("{Actor} opens the %s." % self.name, evoke=True, max_length=True)
16831686
if self.linked_door:
16841687
self.linked_door.opened = True
1685-
self.target.tell("The %s is opened from the other side." % self.linked_door.name, evoke=True, max_length=True)
1688+
self.target.tell("The %s is opened from the other side." % self.linked_door.name, evoke=False, max_length=True)
16861689

16871690
def close(self, actor: Living, item: Item=None) -> None:
16881691
"""Close the door with optional item. Notifies actor and room of this event."""
@@ -1693,7 +1696,7 @@ def close(self, actor: Living, item: Item=None) -> None:
16931696
actor.tell_others("{Actor} closes the %s." % self.name, evoke=True, max_length=True)
16941697
if self.linked_door:
16951698
self.linked_door.opened = False
1696-
self.target.tell("The %s is closed from the other side." % self.linked_door.name, evoke=True, max_length=True)
1699+
self.target.tell("The %s is closed from the other side." % self.linked_door.name, evoke=False, max_length=True)
16971700

16981701
def lock(self, actor: Living, item: Item=None) -> None:
16991702
"""Lock the door with the proper key (optional)."""
@@ -1713,11 +1716,11 @@ def lock(self, actor: Living, item: Item=None) -> None:
17131716
if not key:
17141717
raise ActionRefused("You don't seem to have the means to lock it.")
17151718
self.locked = True
1716-
actor.tell("Your %s fits, the %s is now locked." % (key.title, self.name), evoke=True, max_length=True)
1717-
actor.tell_others("{Actor} locks the %s with %s." % (self.name, lang.a(key.title)), evoke=True, max_length=True)
1719+
actor.tell("Your %s fits, the %s is now locked." % (key.title, self.name), evoke=False, max_length=True)
1720+
actor.tell_others("{Actor} locks the %s with %s." % (self.name, lang.a(key.title)), evoke=False, max_length=True)
17181721
if self.linked_door:
17191722
self.linked_door.locked = True
1720-
self.target.tell("The %s is locked from the other side." % self.linked_door.name, evoke=True, max_length=True)
1723+
self.target.tell("The %s is locked from the other side." % self.linked_door.name, evoke=False, max_length=True)
17211724

17221725
def unlock(self, actor: Living, item: Item=None) -> None:
17231726
"""Unlock the door with the proper key (optional)."""

tale/cmds/normal.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1678,3 +1678,15 @@ def do_account(player: Player, parsed: base.ParseResult, ctx: util.Context) -> N
16781678
player.tell("created: %s (%d days ago)" % (account.created, days_ago.days), end=True)
16791679
player.tell("last login: %s" % account.logged_in, end=True)
16801680
player.tell("\n")
1681+
1682+
@cmd("load_character")
1683+
def do_load_character(player: Player, parsed: base.ParseResult, ctx: util.Context) -> None:
1684+
"""Load a character from file."""
1685+
if len(parsed.args) != 1:
1686+
raise ParseError("You need to specify the path to the character file")
1687+
try:
1688+
path = str(parsed.args[0])
1689+
except ValueError as x:
1690+
raise ActionRefused(str(x))
1691+
1692+
ctx.driver.load_character(player, path)

tale/driver.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
from .tio import DEFAULT_SCREEN_WIDTH
3131
from .races import playable_races
3232
from .errors import StoryCompleted
33+
from tale.load_character import CharacterLoader, CharacterV2
34+
from tale.llm_ext import LivingNpc
3335

3436

3537
topic_pending_actions = pubsub.topic("driver-pending-actions")
@@ -777,6 +779,22 @@ def register_periodicals(self, obj: base.MudObject) -> None:
777779
for func, period in util.get_periodicals(obj).items():
778780
assert len(period) == 3
779781
mud_context.driver.defer(period, func)
782+
783+
def load_character(self, player: player.Player, path: str):
784+
character_loader = CharacterLoader()
785+
char_data = character_loader.load_character(path)
786+
character = CharacterV2().from_json(char_data)
787+
npc = LivingNpc(name = character.name.lower(),
788+
gender = character.gender,
789+
title = character.name,
790+
descr = character.appearance,
791+
short_descr = character.appearance.split('.')[0],
792+
age = character.age,
793+
personality = character.personality,
794+
occupation = character.occupation)
795+
npc.following = player
796+
player.location.insert(npc, None)
797+
780798

781799
@property
782800
def uptime(self) -> Tuple[int, int, int]:

tale/driver_if.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@
2222
from .tio import DEFAULT_SCREEN_DELAY
2323
from .tio import iobase
2424
from .llm_utils import LlmUtil
25-
25+
from tale.load_character import CharacterLoader
2626

2727
class IFDriver(driver.Driver):
2828
"""
2929
The Single user 'driver'.
3030
Used to control interactive fiction where there's only one 'player'.
3131
"""
32-
def __init__(self, *, screen_delay: int=DEFAULT_SCREEN_DELAY, gui: bool=False, web: bool=False, wizard_override: bool=False) -> None:
32+
def __init__(self, *, screen_delay: int=DEFAULT_SCREEN_DELAY, gui: bool=False, web: bool=False, wizard_override: bool=False, character_to_load: str='') -> None:
3333
super().__init__()
3434
self.game_mode = GameMode.IF
3535
if screen_delay < 0 or screen_delay > 100:
@@ -42,6 +42,12 @@ def __init__(self, *, screen_delay: int=DEFAULT_SCREEN_DELAY, gui: bool=False, w
4242
self.io_type = "web"
4343
self.wizard_override = wizard_override
4444
self.llm_util = LlmUtil()
45+
if character_to_load:
46+
character_loader = CharacterLoader()
47+
if '.json' in character_to_load:
48+
self.loaded_character = character_loader.load_from_json(character_to_load)
49+
elif '.png' in character_to_load or '.jpg' in character_to_load:
50+
self.loaded_character = character_loader.load_image(character_to_load)
4551

4652
def start_main_loop(self):
4753
if self.io_type == "web":
@@ -51,6 +57,7 @@ def start_main_loop(self):
5157
else:
5258
print("written by", self.story.config.author)
5359
connection = self.connect_player(self.io_type, self.screen_delay)
60+
self.llm_util.connection = connection
5461
if self.wizard_override:
5562
connection.player.privileges.add("wizard")
5663
# create the login dialog
@@ -153,7 +160,14 @@ def _login_dialog_if(self, conn: PlayerConnection) -> Generator:
153160
conn.player.look(short=False) # force a 'look' command to get our bearings
154161
return
155162

156-
if self.story.config.player_name:
163+
if self.loaded_character:
164+
name_info = charbuilder.PlayerNaming()
165+
name_info.name = self.loaded_character.get('name')
166+
name_info.stats.race = self.loaded_character.get('race', 'human')
167+
name_info.gender = self.loaded_character.get('gender', 'm')
168+
name_info.money = self.loaded_character.get('money', 0.0)
169+
name_info.wizard = "wizard" in conn.player.privileges
170+
elif self.story.config.player_name:
157171
# story config provides a name etc.
158172
name_info = charbuilder.PlayerNaming()
159173
name_info.name = self.story.config.player_name

tale/json_story.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import tale
2+
from tale.base import Location, Item, Living
3+
from tale.driver import Driver
4+
from tale.player import Player
5+
from tale.story import StoryBase, StoryConfig
6+
import tale.parse_utils as parse_utils
7+
8+
class JsonStory(StoryBase):
9+
10+
def __init__(self, path: str, config: StoryConfig):
11+
self.config = config
12+
self.path = path
13+
locs = {}
14+
for zone in self.config.zones:
15+
locs, exits = parse_utils.load_locations(parse_utils.load_json(self.path +'zones/'+zone + '.json'))
16+
self._locations = locs
17+
self.zones = locs
18+
self._npcs = parse_utils.load_npcs(parse_utils.load_json(self.path +'npcs/'+self.config.npcs + '.json'), self._locations)
19+
self._items = parse_utils.load_items(parse_utils.load_json(self.path + self.config.items + '.json'), self._locations)
20+
21+
def init(self, driver) -> None:
22+
pass
23+
24+
25+
def welcome(self, player: Player) -> str:
26+
player.tell("<bright>Welcome to `%s'.</>" % self.config.name, end=True)
27+
player.tell("\n")
28+
player.tell("\n")
29+
return ""
30+
31+
def welcome_savegame(self, player: Player) -> str:
32+
return "" # not supported in demo
33+
34+
def goodbye(self, player: Player) -> None:
35+
player.tell("Thanks for trying out Tale!")
36+
37+
def get_location(self, zone: str, name: str) -> Location:
38+
return self._locations[zone][name]
39+
40+
def get_npc(self, npc: str) -> Living:
41+
return self._npcs[npc]
42+
43+
def get_item(self, item: str) -> Item:
44+
return self._items[item]
45+
46+
@property
47+
def locations(self) -> dict:
48+
return self._locations
49+
50+
@property
51+
def npcs(self) -> dict:
52+
return self._npcs
53+
54+
@property
55+
def items(self) -> dict:
56+
return self._items
57+

tale/llm_config.yaml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
URL: "http://localhost:5001"
22
ENDPOINT: "/api/v1/generate"
3-
DEFAULT_BODY: '{"stop_sequence": "\n\n", "max_length":300, "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}'
4-
MEMORY_SIZE: 1024
3+
STREAM: False
4+
STREAM_ENDPOINT: "/api/extra/generate/stream"
5+
DATA_ENDPOINT: "/api/extra/generate/check"
6+
WORD_LIMIT: 500
7+
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}'
8+
ANALYSIS_BODY: '{"banned_tokens":"\n\n", "stop_sequence": "", "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}'
9+
MEMORY_SIZE: 512
510
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'
6-
BASE_PROMPT: '### Instruction: Rewrite the following text in your own words using vivid language. Text:\n\n {input_text} \n\nEnd of text.\n\n### Response:\n\n'
7-
DIALOGUE_PROMPT: 'The following is a conversation between {character1} and {character2}. {character2_description}. Chat history: {previous_conversation}\n\n### Instruction: Write a single response as {character2}, using {character2} description.\n\n### Response:\n'
8-
ITEM_PROMPT: '### Instruction: Perform an analysis of the following text. Consider whether an item has been given, taken, dropped or put somewhere. Text:\n\n{text}\n\nEnd of text.\n\n Write your response in json format.\n\n Example {\n\n"thoughts":your thoughts on whether an item has been given, taken, dropped or put, and to whom/where.\n\n"result": {"item":"what item?", "from":"?", "to":"?"}\n\n}\n\nInsert the results from your analysis in "item", "from" and "to", or make them empty if no item was given, taken, dropped or put somewhere. Make sure the answer is valid json\n\n### Response:\n\n'
9-
COMBAT_PROMPT: '### Instruction: Rewrite the following combat result into a vivid description. Write one to two paragraphs, ending in either death, or a stalemate. Combat Result: {result}\n\n### Response:\n\n'
11+
BASE_PROMPT: "History: [{history}]. ### Instruction: Rewrite the following text in your own words using vivid language. 'History' can be used to create a context for what you write. Text:\n\n [{input_text}] \n\nEnd of text.\n\n### Response:\n"
12+
DIALOGUE_PROMPT: 'The following is a conversation between {character1} and {character2}. {character2_description}. Chat history: {previous_conversation}\n\n {character2}s sentiment towards {character1}: {sentiment}. ### Instruction: Write a single response as {character2}, using {character2} description.\n\n### Response:\n'
13+
ITEM_PROMPT: '### Instruction: Items:[{items}];Characters:[{character1},{character2}] Text:[{text}] \n\nIn the supplied text, was an item explicitly given, taken, dropped or put somewhere? Insert your thoughts about it in [my thoughts], and the results in "item", "from" and "to". Insert {character1}s sentiment towards {character2} in a single word in [sentiment assessment]. Write your response in JSON format. Example: {{ "thoughts":"[my thoughts]", "result": {{ "item":"", "from":"", "to":""}}, {{"sentiment":"[sentiment assessment]"}} }} End of example. \n\n Make sure the response is valid JSON\n\n### Response:\n'
14+
15+
COMBAT_PROMPT: '### Instruction: Rewrite the following combat result into a vivid description. Write one to two paragraphs, ending in either death, or a stalemate. Combat Result: {result}\n\n### Response:\n'
1016

1117

0 commit comments

Comments
 (0)