Skip to content

Commit c47d447

Browse files
authored
Merge pull request #29 from neph1/improved_combat
improved combat
2 parents 5ee0fc2 + 1af8c40 commit c47d447

File tree

11 files changed

+195
-70
lines changed

11 files changed

+195
-70
lines changed

llm_config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ BASE_PROMPT: "[Story context: {story_context}]; [History: {history}]; ### Instru
1313
ACTION_PROMPT: "[Story context: {story_context}]; [History: {history}]; ### Instruction: Rewrite the Action, and nothing else, in your own words using the supplied Context and Location. History is what happened before. Use less than {max_words} words. [Text: {input_text}] \n\nEnd of text.\n\n### Response:\n"
1414
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'
1515
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'
16-
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'
16+
COMBAT_PROMPT: 'Rewrite the following combat between user {attacker} using {attacker_weapon}, and {victim} using {victim_weapon} in {location}, {location_description} into a vivid description in less than 300 words. ### Instruction: Rewrite the following combat result in about 150 words, using the characters weapons . Combat Result: {attacker_msg} ### Response:\n\n'
1717
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.\n\n'
1818
CREATE_CHARACTER_PROMPT: '### Instruction: For a {story_type}, 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":""}}\n\n ### Response:\n'
1919
CREATE_LOCATION_PROMPT: '[Story context: {story_context}]; Zone info: {zone_info}; Exit example: {{"name" : "location name", "direction":"", "short_descr":"30 words"}}; Item example: {{"name":"", "type":"", "short_descr":"10 words"}}, type can be "Weapon", "Wearable", "Other" or "Money"; Npc example: {{"name":"", "sentiment":"", "race":"", "gender":"m, f, or n", "level":(int), "description":"25 words"}}. Existing connected location: {exit_location}. ### Instruction: For a {story_type}, describe the following location: {location_name}. {items_prompt} {spawn_prompt} Add a brief 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.\n\n ### Response:\n'

stories/prancingllama/npcs/npcs.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from tale import lang
1111
from typing import Optional
1212

13+
from tale.weapon_type import WeaponType
14+
1315
class InnKeeper(LivingNpc):
1416

1517
drink_price = 5.0
@@ -60,6 +62,7 @@ def __init__(self, name: str, gender: str, *,
6062

6163
def init(self) -> None:
6264
self.aliases = {"patron"}
65+
self.stats.set_weapon_skill(weapon_type=WeaponType.UNARMED, value=35)
6366

6467
@call_periodically(75, 180)
6568
def do_idle_action(self, ctx: Context) -> None:
@@ -94,6 +97,9 @@ def do_random_move(self, ctx: Context) -> None:
9497

9598
class Shanda(Patron):
9699

100+
def init(self) -> None:
101+
self.stats.set_weapon_skill(weapon_type=WeaponType.UNARMED, value=65)
102+
97103
def allow_give_item(self, item: Item, actor: Optional[Living]) -> None:
98104
if item.name == "rat_skull":
99105
self.sentiments(actor.title, 'impressed')
@@ -105,6 +111,7 @@ def init(self) -> None:
105111
self.aggressive = False
106112
self.stats.strength = 1
107113
self.stats.agility = 5
114+
self.stats.set_weapon_skill(weapon_type=WeaponType.UNARMED, value=25)
108115

109116
@call_periodically(10, 25)
110117
def do_idle_action(self, ctx: Context) -> None:

stories/prancingllama/story.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from tale.player import Player, PlayerConnection
1111
from tale.charbuilder import PlayerNaming
1212
from tale.story import *
13+
from tale.weapon_type import WeaponType
1314
from tale.zone import Zone
1415

1516
class Story(DynamicStory):
@@ -48,7 +49,9 @@ def init_player(self, player: Player) -> None:
4849
Called by the game driver when it has created the player object (after successful login).
4950
You can set the hint texts on the player object, or change the state object, etc.
5051
"""
51-
pass
52+
player.stats.set_weapon_skill(weapon_type=WeaponType.ONE_HANDED, value=25)
53+
player.stats.set_weapon_skill(weapon_type=WeaponType.TWO_HANDED, value=15)
54+
player.stats.set_weapon_skill(weapon_type=WeaponType.UNARMED, value=35)
5255

5356
def create_account_dialog(self, playerconnection: PlayerConnection, playernaming: PlayerNaming) -> Generator:
5457
"""

stories/prancingllama/zones/prancingllama.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ def spawn_rat(self, ctx: Context) -> None:
3232
outside.built = False # Specify this to be generated by the LLM
3333
cellar = Cellar("Cellar", "A dark and damp place, with cob-webs in the corners. Filled with barrels and boxes of various kind.")
3434

35-
3635
Exit.connect(main_hall, ["bar", "north"], "To the north are some people sitting by a massive bar.", "", bar, ["main hall", "south"], "To the south is an area full of tables with people eating, drinking and talking", "")
3736

3837
Exit.connect(main_hall, ["hearth", "west"], "To the west you see a giant hearth with a comforting fire", "", hearth, ["main hall", "east"], "To the east is an area full of tables with people eating, drinking and talking", "")
@@ -92,8 +91,8 @@ def _generate_character():
9291

9392
# 5 attempts to generate 2 characters
9493
generated = 0
95-
for i in range(5):
96-
if _generate_character():
97-
generated += 1
98-
if generated == 2:
99-
break
94+
# for i in range(5):
95+
# if _generate_character():
96+
# generated += 1
97+
# if generated == 2:
98+
# break

tale/accounts.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def _create_database(self) -> None:
108108
alignment integer NOT NULL,
109109
strength integer NOT NULL,
110110
dexterity integer NOT NULL,
111+
weapon_skills varchar NOT NULL,
111112
FOREIGN KEY(account) REFERENCES Account(id)
112113
);
113114
""")
@@ -159,7 +160,7 @@ def _fetch_account(self, conn: sqlite3.Connection, account_id: int) -> Account:
159160
stats = base.Stats()
160161
for key, value in stats_result.items():
161162
if hasattr(stats, key):
162-
setattr(stats, key, value)
163+
setattr(stats, key, json.loads(value) if isinstance(value, str) and value.startswith('{') else value)
163164
else:
164165
raise AttributeError("stats doesn't have attribute: " + key)
165166
stats.set_stats_from_race() # initialize static stats from races table
@@ -259,7 +260,7 @@ def _store_stats(self, conn: sqlite3.Connection, account_id: int, stats: base.St
259260
del stat_vars[not_stored] # these are not stored, but always initialized from the races table
260261
for key, value in stat_vars.items():
261262
columns.append(key)
262-
values.append(value)
263+
values.append(json.dumps(value) if isinstance(value, dict) else value)
263264
sql = "INSERT INTO CharStat(" + ",".join(columns) + ") VALUES (" + ",".join('?' * len(columns)) + ")"
264265
conn.execute(sql, values)
265266

tale/base.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@
5757
from . import story
5858
from . import verbdefs
5959
from . import combat
60+
6061
from .errors import ActionRefused, ParseError, LocationIntegrityError, TaleError, UnknownVerbException, NonSoulVerb
6162
from tale.races import UnarmedAttack
63+
from tale.weapon_type import WeaponType
6264
from . import wearable
6365

6466
__all__ = ["MudObject", "Armour", 'Container', "Door", "Exit", "Item", "Living", "Stats", "Location", "Weapon", "Key", "Soul"]
@@ -425,7 +427,7 @@ class Item(MudObject):
425427
to check containment.
426428
"""
427429

428-
def __init__(self, name: str, title: str = "", *, descr: str = "", short_descr: str = "", value: int = 0) -> None:
430+
def __init__(self, name: str, title: str = "", *, descr: str = "", short_descr: str = "", value: int = 0, weight: int = 0) -> None:
429431
self.contained_in = None # type: Optional[ContainingType]
430432
self.default_verb = "examine"
431433
self.value = value # what the item is worth
@@ -586,9 +588,15 @@ class Weapon(Item):
586588
An item that can be wielded by a Living (i.e. present in a weapon itemslot),
587589
and that can be used to attack another Living.
588590
"""
589-
def __init__(self, name: str, wc: int = 0, title: str = "", *, descr: str = "", short_descr: str = "") -> None:
590-
super().__init__(name, title, descr=descr, short_descr=short_descr)
591+
def __init__(self, name: str, wc: int = 0, base_damage: int = 1, bonus_damage: int = 0, weapon_type: WeaponType = WeaponType.ONE_HANDED, title: str = "", *, descr: str = "", short_descr: str = "", weight: int = 0, value: int = 0) -> None:
592+
super().__init__(name, title, descr=descr, short_descr=short_descr, weight=weight, value=value)
591593
self.wc = wc
594+
self.base_damage = base_damage # type: int # damage is randomized between 1 and base_damage
595+
self.bonus_damage = bonus_damage # type: int # bonus damage is added to the damage
596+
self.type = weapon_type # type: WeaponType
597+
598+
def __str__(self) -> str:
599+
return "<Weapon '%s' #%d @ 0x%x>" % (self.name, self.vnum, id(self))
592600

593601

594602
class Armour(Item):
@@ -602,9 +610,8 @@ class Armour(Item):
602610
class Wearable(Item):
603611

604612
def __init__(self, name: str, weight: int = 0, value: int = 0, ac: int = 0, wearable_type: str = 'none', title: str = "", *, descr: str = "", short_descr: str = "") -> None:
605-
super().__init__(name, title, descr=descr, short_descr=short_descr)
613+
super().__init__(name, title, descr=descr, short_descr=short_descr, weight=weight, value=value)
606614
self.ac = ac
607-
self.weight = weight
608615
self.type = wearable_type
609616

610617
class Location(MudObject):
@@ -926,10 +933,11 @@ def __init__(self) -> None:
926933
self.race = "" # the name of the race of this creature
927934
self.strength = 3
928935
self.dexterity = 3
929-
self.unarmed_attack = Weapon(UnarmedAttack.FISTS.name)
936+
self.unarmed_attack = Weapon(UnarmedAttack.FISTS.name, weapon_type=WeaponType.UNARMED)
937+
self.weapon_skills = {} # type: Dict[WeaponType, int] # weapon type -> skill level
930938

931939
def __repr__(self):
932-
return "<Stats: %s>" % vars(self)
940+
return "<Stats: %s>" % self.__dict__
933941

934942
@classmethod
935943
def from_race(cls: type, race: builtins.str, gender: builtins.str='n') -> 'Stats':
@@ -952,6 +960,11 @@ def set_stats_from_race(self) -> None:
952960
self.hp = r.hp
953961
self.unarmed_attack = Weapon(name=r.unarmed_attack.name)
954962

963+
def get_weapon_skill(self, weapon_type: WeaponType) -> int:
964+
return self.weapon_skills.get(weapon_type, 0)
965+
966+
def set_weapon_skill(self, weapon_type: WeaponType, value: int) -> None:
967+
self.weapon_skills[weapon_type] = value
955968

956969
class Living(MudObject):
957970
"""
@@ -1363,7 +1376,8 @@ def start_attack(self, victim: 'Living') -> None:
13631376
"""Starts attacking the given living until death ensues on either side."""
13641377
attacker_name = lang.capital(self.title)
13651378
victim_name = lang.capital(victim.title)
1366-
result, damage_to_attacker, damage_to_defender = combat.resolve_attack(self, victim)
1379+
c = combat.Combat(self, victim)
1380+
result, damage_to_attacker, damage_to_defender = c.resolve_attack()
13671381

13681382
room_msg = "%s attacks %s! %s" % (attacker_name, victim_name, result)
13691383
victim_msg = "%s attacks you. %s" % (attacker_name, result)
@@ -1373,7 +1387,7 @@ def start_attack(self, victim: 'Living') -> None:
13731387
combat_prompt = mud_context.driver.prepare_combat_prompt(attacker=self,
13741388
victim=victim,
13751389
location_title = self.location.title,
1376-
location_description = self.location.short_description,
1390+
location_description = self.location.description,
13771391
attacker_msg = attacker_msg)
13781392
victim.location.tell(room_msg,
13791393
exclude_living=victim,

tale/combat.py

Lines changed: 78 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,63 +5,94 @@
55
"""
66

77
import random
8+
from tale import weapon_type
89
import tale.util as util
910
import tale.base as base
1011
from tale.util import Context
1112

12-
def resolve_attack(attacker, victim):
13-
damage_to_attacker, damage_to_defender = combat(attacker, victim)
14-
text = 'After a fierce exchange of attacks '
15-
if damage_to_attacker > 0:
16-
text = text + f', {attacker.title} is injured '
17-
if damage_to_defender > 0:
18-
text = text + f', {victim.title} is injured '
19-
if victim.stats.hp - damage_to_defender < 1:
20-
text = text + f', {victim.title} dies.'
21-
if attacker.stats.hp - damage_to_attacker < 1:
22-
text = text + f', {attacker.title} dies.'
23-
24-
return text, damage_to_attacker, damage_to_defender
25-
26-
def combat(actor1: 'base.Living', actor2: 'base.Living'):
27-
""" Simple combat, but rather complex logic. Credit to ChatGPT."""
28-
29-
def calculate_attack(actor: 'base.Living'):
30-
# The attack strength depends on the level and strength of the actor
31-
return actor.stats.level * actor.stats.dexterity
32-
33-
def calculate_defense(actor: 'base.Living'):
34-
# The defense strength depends on the level and dexterity of the actor
35-
return actor.stats.level * actor.stats.dexterity
36-
37-
def calculate_weapon_bonus(actor: 'base.Living'):
38-
# The weapon bonus is a random factor between 0 and 1. If the actor has no weapon, bonus is 1.
39-
return actor.stats.wc + 1
40-
41-
def calculate_armor_bonus(actor: 'base.Living'):
42-
# The armor bonus is a random factor between 0 and 1. If the actor has no armor, bonus is 1.
43-
return actor.stats.ac + 1
13+
class Combat():
4414

45-
def calculate_damage(attacker: 'base.Living', defender: 'base.Living'):
46-
# Calculate the damage done by the attacker to the defender
47-
attack_strength = calculate_attack(attacker) * calculate_weapon_bonus(attacker) * attacker.stats.strength * attacker.stats.size.order
48-
defense_strength = calculate_defense(defender) * calculate_armor_bonus(defender) * defender.stats.strength * defender.stats.size.order
49-
damage = max(0, attack_strength - defense_strength)
50-
return damage
51-
52-
# Calculate the chances of actor1 and actor2 winning
53-
damage_to_actor1 = calculate_damage(actor2, actor1)
54-
damage_to_actor2 = calculate_damage(actor1, actor2)
15+
def __init__(self, attacker: 'base.Living', defender: 'base.Living') -> None:
16+
self.attacker = attacker
17+
self.defender = defender
18+
19+
def _calculate_attack_success(self, actor: 'base.Living') -> int:
20+
""" Calculate the success of an attack. <5 is a critical hit."""
21+
return random.randrange(0, 100) - actor.stats.get_weapon_skill(actor.wielding.type)
22+
23+
def _calculate_block_success(self, actor1: 'base.Living', actor2: 'base.Living') -> int:
24+
""" Calculate the chance of blocking an attack.
25+
If the attacker is wielding a ranged weapon, the defender can't block.
26+
If the defender is wielding a ranged weapon, the defender can't block.
27+
"""
28+
if actor1.wielding.type in weapon_type.ranged:
29+
# can't block ranged weapons
30+
return 100
31+
if actor2.wielding.type in weapon_type.ranged:
32+
# can't block with a ranged weapon
33+
return 100
34+
return random.randrange(0, 100) - actor2.stats.get_weapon_skill(actor2.wielding.type)
35+
36+
def _calculate_weapon_bonus(self, actor: 'base.Living'):
37+
weapon = actor.wielding
38+
return actor.stats.wc + 1 + random.randint(1, weapon.base_damage) + weapon.bonus_damage
5539

56-
# Use some randomness to introduce unpredictability
57-
damage_to_actor1 *= random.uniform(0.9, 1.1)
58-
damage_to_actor2 *= random.uniform(0.9, 1.1)
40+
def _calculate_armor_bonus(self, actor: 'base.Living'):
41+
return actor.stats.ac + 1
42+
43+
def resolve_attack(self) -> (str, int, int):
44+
""" Both attacker and defender attack each other once.
45+
They get a chance to block, unless it's a critical hit, 5/100.
46+
Returns a textual representation of the combat and the damage done to each actor.
47+
"""
48+
texts = []
49+
damage_to_defender = 0
50+
damage_to_attacker = 0
51+
52+
text_result, damage_to_defender = self._round(self.attacker, self.defender)
53+
texts.extend(text_result)
5954

60-
return int(damage_to_actor1), int(damage_to_actor2)
55+
text_result, damage_to_attacker = self._round(self.defender, self.attacker)
56+
texts.extend(text_result)
57+
58+
return ', '.join(texts), damage_to_attacker, damage_to_defender
6159

60+
def _round(self, actor1: 'base.Living', actor2: 'base.Living') -> ([str], int):
61+
attack_result = self._calculate_attack_success(actor1)
62+
texts = []
63+
if attack_result < 0:
64+
if attack_result < -actor1.stats.get_weapon_skill(actor1.wielding.type) + 5:
65+
texts.append(f'{actor1.title} performs a critical hit')
66+
block_result = 100
67+
else:
68+
texts.append(f'{actor1.title} hits')
69+
block_result = self._calculate_block_success(actor1, actor2)
70+
71+
if block_result < 0:
72+
texts.append(f'{actor2.title} blocks')
73+
else:
74+
actor1_strength = self._calculate_weapon_bonus(actor1) * actor1.stats.size.order
75+
actor2_strength = self._calculate_armor_bonus(actor2) * actor2.stats.size.order
76+
damage_to_defender = int(max(0, actor1_strength - actor2_strength))
77+
if damage_to_defender > 0:
78+
texts.append(f', {actor2.title} is injured')
79+
else:
80+
texts.append(f', {actor2.title} is unharmed')
81+
if actor2.stats.hp - damage_to_defender < 1:
82+
texts.append(f', {actor2.title} dies')
83+
return texts, damage_to_defender
84+
elif attack_result > 50:
85+
texts.append(f'{actor1.title} misses completely')
86+
elif attack_result > 25:
87+
texts.append(f'{actor1.title} misses')
88+
else:
89+
texts.append(f'{actor1.title} barely misses')
90+
return texts, 0
6291

6392
def produce_remains(context: Context, actor: 'base.Living'):
64-
# Produce the remains of the actor
93+
""" Creates a container with the inventory of the Living
94+
and places it in the living's location.
95+
"""
6596
remains = base.Container(f"remains of {actor.title}")
6697
remains.init_inventory(actor.inventory)
6798
actor.location.insert(remains, None)

0 commit comments

Comments
 (0)