diff --git a/llm_config.yaml b/llm_config.yaml index 965a7a21..2bff0f1d 100644 --- a/llm_config.yaml +++ b/llm_config.yaml @@ -17,7 +17,7 @@ CHARACTER_TEMPLATE: '{"name":"", "description": "50 words", "appearance": "25 wo FOLLOW_TEMPLATE: '{{"response":"yes or no", "reason":"50 words"}}' ITEM_TYPES: ["Weapon", "Wearable", "Health", "Money", "Trash", "Food", "Drink", "Key"] PRE_PROMPT: 'You are a creative game keeper for a role playing game (RPG). You craft detailed worlds and interesting characters with unique and deep personalities for the player to interact with. Do not acknowledge the task or speak directly to the user, just perform it.' -BASE_PROMPT: '{context}\n[USER_START]Rewrite [{input_text}] in your own words using the information found inside the tags to create a background for your text. Use about {max_words} words.' +BASE_PROMPT: '{context}\n[USER_START] Rewrite [{input_text}] in your own words. The information inside the tags should be used to ensure it fits the story. Use about {max_words} words.' DIALOGUE_PROMPT: '{context}\nThe following is a conversation between {character1} and {character2}; {character2}s sentiment towards {character1}: {sentiment}. Write a single response as {character2} in third person pov, using {character2} description and other information found inside the tags. If {character2} has a quest active, they will discuss it based on its status. Respond in JSON using this template: """{dialogue_template}""". [USER_START]Continue the following conversation as {character2}: {previous_conversation}' COMBAT_PROMPT: '{context}\nThe following is a combat scene between {attackers} and {defenders} in {location}. [USER_START] Describe the following combat result in about 150 words in vivid language, using the characters weapons and their health status: 1.0 is highest, 0.0 is lowest. Combat Result: {input_text}' PRE_JSON_PROMPT: 'Below is an instruction that describes a task, paired with an input that provides further context. Write a response in valid JSON format that appropriately completes the request.' diff --git a/stories/anything/story.py b/stories/anything/story.py index 50075060..9eaaedcd 100644 --- a/stories/anything/story.py +++ b/stories/anything/story.py @@ -8,6 +8,7 @@ from tale import parse_utils from tale.driver import Driver from tale.json_story import JsonStory +from tale.magic import MagicType from tale.main import run_from_cmdline from tale.player import Player, PlayerConnection from tale.charbuilder import PlayerNaming @@ -33,6 +34,10 @@ def init_player(self, player: Player) -> None: player.stats.set_weapon_skill(weapon_type=WeaponType.ONE_HANDED, value=random.randint(10, 30)) player.stats.set_weapon_skill(weapon_type=WeaponType.TWO_HANDED, value=random.randint(10, 30)) player.stats.set_weapon_skill(weapon_type=WeaponType.UNARMED, value=random.randint(20, 30)) + player.stats.magic_skills[MagicType.HEAL] = 30 + player.stats.magic_skills[MagicType.BOLT] = 30 + player.stats.magic_skills[MagicType.DRAIN] = 30 + player.stats.magic_skills[MagicType.REJUVENATE] = 30 pass def create_account_dialog(self, playerconnection: PlayerConnection, playernaming: PlayerNaming) -> Generator: diff --git a/stories/dungeon/story.py b/stories/dungeon/story.py index 1368b073..aed6e5b7 100644 --- a/stories/dungeon/story.py +++ b/stories/dungeon/story.py @@ -11,6 +11,7 @@ from tale.dungeon.dungeon_generator import ItemPopulator, Layout, LayoutGenerator, MobPopulator from tale.items.basic import Money from tale.json_story import JsonStory +from tale.magic import MagicType from tale.main import run_from_cmdline from tale.npc_defs import RoamingMob from tale.player import Player, PlayerConnection @@ -44,6 +45,10 @@ def init_player(self, player: Player) -> None: player.stats.set_weapon_skill(weapon_type=WeaponType.ONE_HANDED, value=random.randint(10, 30)) player.stats.set_weapon_skill(weapon_type=WeaponType.TWO_HANDED, value=random.randint(10, 30)) player.stats.set_weapon_skill(weapon_type=WeaponType.UNARMED, value=random.randint(20, 30)) + player.stats.magic_skills[MagicType.HEAL] = 30 + player.stats.magic_skills[MagicType.BOLT] = 30 + player.stats.magic_skills[MagicType.DRAIN] = 30 + player.stats.magic_skills[MagicType.REJUVENATE] = 30 pass def create_account_dialog(self, playerconnection: PlayerConnection, playernaming: PlayerNaming) -> Generator: diff --git a/stories/prancingllama/story.py b/stories/prancingllama/story.py index 3de2bd06..4ee177c0 100644 --- a/stories/prancingllama/story.py +++ b/stories/prancingllama/story.py @@ -4,8 +4,10 @@ import tale from tale.base import Location +from tale.cmds import spells from tale.driver import Driver from tale.llm.llm_ext import DynamicStory +from tale.magic import MagicType from tale.main import run_from_cmdline from tale.player import Player, PlayerConnection from tale.charbuilder import PlayerNaming @@ -55,6 +57,7 @@ def init_player(self, player: Player) -> None: player.stats.set_weapon_skill(weapon_type=WeaponType.ONE_HANDED, value=25) player.stats.set_weapon_skill(weapon_type=WeaponType.TWO_HANDED, value=15) player.stats.set_weapon_skill(weapon_type=WeaponType.UNARMED, value=35) + player.stats.magic_skills[MagicType.HEAL] = 50 def create_account_dialog(self, playerconnection: PlayerConnection, playernaming: PlayerNaming) -> Generator: """ diff --git a/stories/prancingllama/zones/prancingllama.py b/stories/prancingllama/zones/prancingllama.py index 6d565bf7..ee38363e 100644 --- a/stories/prancingllama/zones/prancingllama.py +++ b/stories/prancingllama/zones/prancingllama.py @@ -1,10 +1,8 @@ import random -from tale.base import Location, Item, Exit, Door, Key, Living, ParseResult, Weapon +from tale.base import Location, Item, Exit, Weapon from tale.errors import StoryCompleted from tale.items.basic import Note -from tale.lang import capital -from tale.player import Player from tale.util import Context, call_periodically from tale.verbdefs import AGGRESSIVE_VERBS from tale.verbdefs import VERBS diff --git a/tale/accounts.py b/tale/accounts.py index f3ecfeb3..c60a229c 100644 --- a/tale/accounts.py +++ b/tale/accounts.py @@ -110,8 +110,11 @@ def _create_database(self) -> None: strength integer NOT NULL, dexterity integer NOT NULL, weapon_skills varchar NOT NULL, + magic_skills varchar NOT NULL, combat_points integer NOT NULL, max_combat_points integer NOT NULL, + magic_points integer NOT NULL, + max_magic_points integer NOT NULL, FOREIGN KEY(account) REFERENCES Account(id) ); """) diff --git a/tale/base.py b/tale/base.py index ca2ac02c..ff508fe8 100644 --- a/tale/base.py +++ b/tale/base.py @@ -50,6 +50,7 @@ from tale.coord import Coord from tale.llm.contexts.CombatContext import CombatContext +from tale.magic import MagicSkill, MagicType from . import lang from . import mud_context @@ -975,8 +976,11 @@ def __init__(self) -> None: self.dexterity = 3 self.unarmed_attack = Weapon(UnarmedAttack.FISTS.name, weapon_type=WeaponType.UNARMED) self.weapon_skills = {} # type: Dict[WeaponType, int] # weapon type -> skill level + self.magic_skills = {} # type: Dict[MagicType, MagicSkill] self.combat_points = 0 # combat points self.max_combat_points = 5 # max combat points + self.max_magic_points = 5 # max magic points + self.magic_points = 0 # magic points def __repr__(self): return "" % self.__dict__ @@ -1025,6 +1029,14 @@ def replenish_combat_points(self, amount: int = None) -> None: if self.combat_points > self.max_combat_points: self.combat_points = self.max_combat_points + def replenish_magic_points(self, amount: int = None) -> None: + if amount: + self.magic_points += amount + else: + self.magic_points = self.max_magic_points + if self.magic_points > self.max_magic_points: + self.magic_points = self.max_magic_points + class Living(MudObject): """ A living entity in the mud world (also known as an NPC). diff --git a/tale/cmds/spells.py b/tale/cmds/spells.py new file mode 100644 index 00000000..03c9fc55 --- /dev/null +++ b/tale/cmds/spells.py @@ -0,0 +1,141 @@ + +import random +from typing import Optional +from tale import base, util, cmds +from tale import magic +from tale.cmds import cmd +from tale.errors import ActionRefused, ParseError +from tale.magic import MagicType, MagicSkill, Spell +from tale.player import Player + + +@cmd("heal") +def do_heal(player: Player, parsed: base.ParseResult, ctx: util.Context) -> None: + """ Heal someone or something """ + + skillValue = player.stats.magic_skills.get(MagicType.HEAL, None) + if not skillValue: + raise ActionRefused("You don't know how to heal") + spell = magic.spells[MagicType.HEAL] # type: Spell + + num_args = len(parsed.args) + + level = player.stats.level + if num_args == 2: + level = int(parsed.args[1]) + + if not spell.check_cost(player.stats.magic_points, level): + raise ActionRefused("You don't have enough magic points") + + if num_args < 1: + raise ParseError("You need to specify who or what to heal") + try: + entity = str(parsed.args[0]) + except ValueError as x: + raise ActionRefused(str(x)) + + result = player.location.search_living(entity) # type: Optional[base.Living] + if not result or not isinstance(result, base.Living): + raise ActionRefused("Can't heal that") + + + player.stats.magic_points -= spell.base_cost * level + + if random.randint(1, 100) > skillValue: + player.tell("Your healing spell fizzles out", evoke=True, short_len=True) + return + + result.stats.replenish_hp(5 * level) + player.tell("You cast a healing spell that heals %s for %d hit points" % (result.name, 5 * level), evoke=True) + player.tell_others("%s casts a healing spell that heals %s" % (player.name, result.name), evoke=True) + + +@cmd("bolt") +def do_bolt(player: Player, parsed: base.ParseResult, ctx: util.Context) -> None: + """ Cast a bolt of energy """ + + skillValue = player.stats.magic_skills.get(MagicType.BOLT, None) + if not skillValue: + raise ActionRefused("You don't know how to cast a bolt") + + spell = magic.spells[MagicType.BOLT] # type: Spell + + num_args = len(parsed.args) + + level = player.stats.level + if num_args == 2: + level = int(parsed.args[1]) + + if not spell.check_cost(player.stats.magic_points, level): + raise ActionRefused("You don't have enough magic points") + + if num_args < 1: + raise ParseError("You need to specify who or what to attack") + + try: + entity = str(parsed.args[0]) + except ValueError as x: + raise ActionRefused(str(x)) + + result = player.location.search_living(entity) # type: Optional[base.Living] + if not result or not isinstance(result, base.Living): + raise ActionRefused("Can't attack that") + + player.stats.magic_points -= spell.base_cost * level + + if random.randint(1, 100) > skillValue: + player.tell("Your bolt spell fizzles out", evoke=True, short_len=True) + return + + hp = random.randint(1, level) + result.stats.hp -= hp + player.tell("You cast an energy bolt that hits %s for %d damage" % (result.name, hp), evoke=True) + player.tell_others("%s casts an energy bolt that hits %s for %d damage" % (player.name, result.name, hp), evoke=True) + +@cmd("drain") +def do_drain(player: Player, parsed: base.ParseResult, ctx: util.Context) -> None: + """ Drain energy from someone or something """ + + skillValue = player.stats.magic_skills.get(MagicType.DRAIN, None) + if not skillValue: + raise ActionRefused("You don't know how to drain") + + spell = magic.spells[MagicType.DRAIN] # type: Spell + + num_args = len(parsed.args) + + level = player.stats.level + if num_args == 2: + level = int(parsed.args[1]) + + if not spell.check_cost(player.stats.magic_points, level): + raise ActionRefused("You don't have enough magic points") + + if num_args < 1: + raise ParseError("You need to specify who or what to drain") + + try: + entity = str(parsed.args[0]) + except ValueError as x: + raise ActionRefused(str(x)) + + result = player.location.search_living(entity) # type: Optional[base.Living] + if not result or not isinstance(result, base.Living): + raise ActionRefused("Can't drain that") + + player.stats.magic_points -= spell.base_cost * level + + if random.randint(1, 100) > skillValue: + player.tell("Your drain spell fizzles out", evoke=True, short_len=True) + return + + points = random.randint(1, level) + result.stats.combat_points -= points + result.stats.magic_points -= points + + player.stats.magic_points += points + + player.tell("You cast a 'drain' spell that drains %s of %d combat and magic points" % (result.name, points), evoke=True) + player.tell_others("%s casts a 'drain' spell that drains energy from %s" % (player.name, result.name), evoke=True) + + diff --git a/tale/driver.py b/tale/driver.py index 220a3837..9ab3882c 100644 --- a/tale/driver.py +++ b/tale/driver.py @@ -961,8 +961,9 @@ def build_location(self, targetLocation: base.Location, zone: Zone, player: play def do_on_player_death(self, player: player.Player) -> None: pass - @util.call_periodically(10) + @util.call_periodically(20) def replenish(self): for player in self.all_players.values(): player.player.stats.replenish_hp(1) - player.player.stats.replenish_combat_points(1) \ No newline at end of file + player.player.stats.replenish_combat_points(1) + player.player.stats.replenish_magic_points(1) \ No newline at end of file diff --git a/tale/items/basic.py b/tale/items/basic.py index 1f9ce611..5dd34f7b 100644 --- a/tale/items/basic.py +++ b/tale/items/basic.py @@ -220,7 +220,6 @@ def init(self) -> None: def consume(self, actor: 'Living'): if self not in actor.inventory: raise ActionRefused("You don't have that.") - actor.stats.hp += self.affect_thirst actor.tell("You drink the %s." % (self.title), evoke=True, short_len=True) actor.tell_others("{Actor} drinks the %s." % (self.title)) self.destroy(util.Context.from_global()) @@ -243,7 +242,6 @@ def init(self) -> None: def consume(self, actor: 'Living'): if self not in actor.inventory: raise ActionRefused("You don't have that.") - actor.stats.hp += self.affect_fullness actor.tell("You eat the %s." % (self.title), evoke=True, short_len=True) actor.tell_others("{Actor} eats the %s." % (self.title)) self.destroy(util.Context.from_global()) diff --git a/tale/magic.py b/tale/magic.py new file mode 100644 index 00000000..33384d41 --- /dev/null +++ b/tale/magic.py @@ -0,0 +1,32 @@ +from abc import ABC +from enum import Enum + + +class MagicType(Enum): + HEAL = 1 + BOLT = 2 + DRAIN = 3 + REJUVENATE = 4 + + + +class Spell(ABC): + def __init__(self, name: str, base_cost: int, base_value: int = 1, max_level: int = -1): + self.name = name + self.base_value = base_value + self.base_cost = base_cost + self.max_level = max_level + + def check_cost(self, magic_points: int, level: int) -> bool: + return magic_points >= self.base_cost * level + +spells = { + MagicType.HEAL: Spell('heal', base_cost=2, base_value=5), + MagicType.BOLT: Spell('bolt', base_cost=3, base_value=5), + MagicType.DRAIN: Spell('drain', base_cost=3, base_value=5) +} + +class MagicSkill: + def __init__(self, spell: Spell, skill: int = 0): + self.spell = spell + self.skill = skill diff --git a/tale/npc_defs.py b/tale/npc_defs.py index ac69fa5a..a6fc9303 100644 --- a/tale/npc_defs.py +++ b/tale/npc_defs.py @@ -40,12 +40,9 @@ def do_idle_action(self, ctx: Context) -> None: if player_in_location and self.aggressive and not self.attacking: for liv in self.location.livings: if isinstance(liv, Player): - self.do_attack(liv) + self.start_attack(defender=liv) elif player_in_location or self.location.get_wiretap() or self.get_wiretap(): self.idle_action() - - def do_attack(self, target: Living) -> None: - self.start_attack(defender=target) class RoamingMob(StationaryMob): diff --git a/tale/parse_utils.py b/tale/parse_utils.py index 02b7e4ad..1b448e4a 100644 --- a/tale/parse_utils.py +++ b/tale/parse_utils.py @@ -7,6 +7,7 @@ from tale.item_spawner import ItemSpawner from tale.items.basic import Boxlike, Drink, Food, Health, Money, Note from tale.llm.LivingNpc import LivingNpc +from tale.magic import MagicType from tale.npc_defs import StationaryMob, StationaryNpc from tale.races import BodyType, UnarmedAttack from tale.mob_spawner import MobSpawner @@ -160,7 +161,7 @@ def load_npcs(json_npcs: list, locations = {}) -> dict: npcs[name] = new_npc return npcs -def load_npc(npc: dict, name: str = None, npc_type: str = 'Mob'): +def load_npc(npc: dict, name: str = None, npc_type: str = 'Mob', roaming = False): race = None if npc.get('stats', None): race = npc['stats'].get('race', None) @@ -641,7 +642,8 @@ def save_stats(stats: Stats) -> dict: json_stats['hp'] = stats.hp json_stats['max_hp'] = stats.max_hp json_stats['level'] = stats.level - json_stats['weapon_skills'] = save_weaponskills(stats.weapon_skills) + json_stats['weapon_skills'] = skills_dict_to_json(stats.weapon_skills) + json_stats['magic_skills'] = skills_dict_to_json(stats.magic_skills) json_stats['gender'] = stats.gender = 'n' json_stats['alignment'] = stats.alignment json_stats['weight'] = stats.weight @@ -679,6 +681,12 @@ def load_stats(json_stats: dict) -> Stats: for skill in json_skills.keys(): int_skill = int(skill) stats.weapon_skills[WeaponType(int_skill)] = json_skills[skill] + if json_stats.get('magic_skills'): + json_skills = json_stats['magic_skills'] + stats.magic_skills = {} + for skill in json_skills.keys(): + int_skill = int(skill) + stats.magic_skills[MagicType(int_skill)] = json_skills[skill] return stats def save_items(items: List[Item]) -> dict: @@ -715,7 +723,7 @@ def save_locations(locations: List[Location]) -> dict: json_locations.append(json_location) return json_locations -def save_weaponskills(weaponskills: dict) -> dict: +def skills_dict_to_json(weaponskills: dict) -> dict: json_skills = {} for skill in weaponskills.keys(): json_skills[skill.value] = weaponskills[skill] diff --git a/tests/test_spells.py b/tests/test_spells.py new file mode 100644 index 00000000..46edd113 --- /dev/null +++ b/tests/test_spells.py @@ -0,0 +1,134 @@ + + +import pytest +from tale import magic +import tale +from tale.base import Location, ParseResult +from tale.cmds import spells +from tale.errors import ActionRefused +from tale.llm.LivingNpc import LivingNpc +from tale.llm.llm_ext import DynamicStory +from tale.llm.llm_utils import LlmUtil +from tale.magic import MagicSkill, MagicType +from tale.player import Player +from tale.story import StoryConfig +from tests.supportstuff import FakeDriver, FakeIoUtil + + +class TestHeal: + + context = tale.mud_context + context.config = StoryConfig() + + io_util = FakeIoUtil(response=[]) + io_util.stream = False + llm_util = LlmUtil(io_util) + story = DynamicStory() + llm_util.set_story(story) + + def setup_method(self): + tale.mud_context.driver = FakeDriver() + tale.mud_context.driver.story = DynamicStory() + tale.mud_context.driver.llm_util = self.llm_util + self.player = Player('player', 'f') + self.location = Location('test_location') + self.location.insert(self.player, actor=None) + + def test_heal(self): + self.player.stats.magic_skills[MagicType.HEAL] = 100 + npc = LivingNpc('test', 'f', age=30) + npc.stats.hp = 0 + self.player.location.insert(npc, actor=None) + self.player.stats.magic_points = 10 + parse_result = ParseResult(verb='heal', args=['test']) + result = spells.do_heal(self.player, parse_result, None) + assert self.player.stats.magic_points == 8 + assert npc.stats.hp == 5 + + def test_heal_fail(self): + self.player.stats.magic_skills[MagicType.HEAL] = -1 + npc = LivingNpc('test', 'f', age=30) + npc.stats.hp = 0 + self.player.location.insert(npc, actor=None) + self.player.stats.magic_points = 10 + parse_result = ParseResult(verb='heal', args=['test']) + result = spells.do_heal(self.player, parse_result, None) + assert self.player.stats.magic_points == 8 + assert npc.stats.hp == 0 + + def test_heal_refused(self): + parse_result = ParseResult(verb='heal', args=['test']) + with pytest.raises(ActionRefused, match="You don't know how to heal"): + spells.do_heal(self.player, parse_result, None) + + self.player.stats.magic_skills[MagicType.HEAL] = 10 + self.player.stats.magic_points = 0 + + npc = LivingNpc('test', 'f', age=30) + npc.stats.hp = 0 + self.player.location.insert(npc, actor=None) + + with pytest.raises(ActionRefused, match="You don't have enough magic points"): + spells.do_heal(self.player, parse_result, None) + +class TestBolt: + + context = tale.mud_context + context.config = StoryConfig() + + io_util = FakeIoUtil(response=[]) + io_util.stream = False + llm_util = LlmUtil(io_util) + story = DynamicStory() + llm_util.set_story(story) + + def setup_method(self): + tale.mud_context.driver = FakeDriver() + tale.mud_context.driver.story = DynamicStory() + tale.mud_context.driver.llm_util = self.llm_util + self.player = Player('player', 'f') + self.location = Location('test_location') + self.location.insert(self.player, actor=None) + + def test_bolt(self): + self.player.stats.magic_skills[MagicType.BOLT] = 100 + npc = LivingNpc('test', 'f', age=30) + npc.stats.hp = 5 + self.player.location.insert(npc, actor=None) + self.player.stats.magic_points = 10 + parse_result = ParseResult(verb='bolt', args=['test']) + result = spells.do_bolt(self.player, parse_result, None) + assert self.player.stats.magic_points == 7 + assert npc.stats.hp < 5 + +class TestDrain: + + context = tale.mud_context + context.config = StoryConfig() + + io_util = FakeIoUtil(response=[]) + io_util.stream = False + llm_util = LlmUtil(io_util) + story = DynamicStory() + llm_util.set_story(story) + + def setup_method(self): + tale.mud_context.driver = FakeDriver() + tale.mud_context.driver.story = DynamicStory() + tale.mud_context.driver.llm_util = self.llm_util + self.player = Player('player', 'f') + self.location = Location('test_location') + self.location.insert(self.player, actor=None) + + def test_drain(self): + self.player.stats.magic_skills[MagicType.DRAIN] = 100 + npc = LivingNpc('test', 'f', age=30) + npc.stats.combat_points = 5 + npc.stats.magic_points = 5 + self.player.location.insert(npc, actor=None) + self.player.stats.magic_points = 10 + parse_result = ParseResult(verb='drain', args=['test']) + result = spells.do_drain(self.player, parse_result, None) + assert self.player.stats.magic_points > 7 + assert npc.stats.combat_points < 5 + assert npc.stats.magic_points < 5 \ No newline at end of file