Compare commits

..

No commits in common. "551140b5bce7f4f9371fa6eababe53250995c63e" and "a8bb6de0085ba65dc7499be790a9b241a3d754d5" have entirely different histories.

13 changed files with 96 additions and 393 deletions

View File

@ -9,7 +9,7 @@ packages = [
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.11" python = "^3.10"
python-dotenv = "^0.21.0" python-dotenv = "^0.21.0"
typer = "^0.9.0" typer = "^0.9.0"
rich = "^13.7.0" rich = "^13.7.0"

View File

@ -80,11 +80,11 @@ def serve(
""" """
# delay loading the app until we have configured our environment # delay loading the app until we have configured our environment
from ttfrog.db.bootstrap import loader from ttfrog.db.bootstrap import bootstrap
from ttfrog.webserver import application from ttfrog.webserver import application
print("Starting TableTop Frog server...") print("Starting TableTop Frog server...")
loader.load() bootstrap()
application.start(host=host, port=port, debug=debug) application.start(host=host, port=port, debug=debug)
@ -93,13 +93,13 @@ def setup(context: typer.Context):
""" """
(Re)Initialize TableTop Frog. Idempotent; will preserve any existing configuration. (Re)Initialize TableTop Frog. Idempotent; will preserve any existing configuration.
""" """
from ttfrog.db.bootstrap import loader from ttfrog.db.bootstrap import bootstrap
if not os.path.exists(app_state["env"]): if not os.path.exists(app_state["env"]):
app_state["env"].parent.mkdir(parents=True, exist_ok=True) app_state["env"].parent.mkdir(parents=True, exist_ok=True)
app_state["env"].write_text(dedent(SETUP_HELP)) app_state["env"].write_text(dedent(SETUP_HELP))
print(f"Wrote defaults file {app_state['env']}.") print(f"Wrote defaults file {app_state['env']}.")
loader.load() bootstrap()
@db_app.command() @db_app.command()

View File

@ -0,0 +1,36 @@
from ttfrog.db import schema
from ttfrog.db.manager import db
def bootstrap():
db.metadata.drop_all(bind=db.engine)
db.init()
with db.transaction():
# ancestries
human = schema.Ancestry("human")
tiefling = schema.Ancestry("tiefling")
tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="intelligence", relative_value=1))
tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="charisma", relative_value=2))
darkvision = schema.AncestryTrait(
"Darkvision",
description=(
"You can see in dim light within 60 feet of you as if it were bright light, and in darkness as if it "
"were dim light. You cant discern color in darkness, only shades of gray."
),
)
darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120))
tiefling.add_trait(darkvision)
# classes
fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON")
rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX")
# characters
sabetha = schema.Character("Sabetha", ancestry=tiefling, _intelligence=14)
sabetha.add_class(fighter, level=2)
sabetha.add_class(rogue, level=3)
bob = schema.Character("Bob", ancestry=human)
# persist all the records we've created
db.add_or_update([sabetha, bob])

View File

@ -1,208 +0,0 @@
{
"ancestry": [
{
"id": 1,
"name": "human",
"creature_type": "humanoid",
"size": "medium",
"speed": 30,
"_fly_speed": null,
"_climb_speed": null,
"_swim_speed": null
},
{
"id": 2,
"name": "tiefling",
"creature_type": "humanoid",
"size": "medium",
"speed": 30,
"_fly_speed": null,
"_climb_speed": null,
"_swim_speed": null
}
],
"ancestry_trait": [
{
"id": 1,
"name": "Darkvision",
"description": "You can see in dim light within 60 feet of you as if it were bright light, and in darkness as if it were dim light. You can\u2019t discern color in darkness, only shades of gray."
}
],
"character_class": [
{
"id": 1,
"name": "fighter",
"hit_die_name": "1d10",
"hit_die_stat_name": "constitution",
"starting_skills": 0
},
{
"id": 2,
"name": "rogue",
"hit_die_name": "1d8",
"hit_die_stat_name": "dexterity",
"starting_skills": 0
}
],
"class_attribute": [],
"modifier": [
{
"id": 1,
"name": "Darkvision",
"target": "vision_in_darkness",
"stacks": false,
"absolute_value": 120,
"multiply_value": null,
"multiply_attribute": null,
"relative_value": null,
"relative_attribute": null,
"new_value": null,
"description": ""
},
{
"id": 2,
"name": "Ability Score Increase",
"target": "intelligence",
"stacks": false,
"absolute_value": null,
"multiply_value": null,
"multiply_attribute": null,
"relative_value": 1,
"relative_attribute": null,
"new_value": null,
"description": ""
},
{
"id": 3,
"name": "Ability Score Increase",
"target": "charisma",
"stacks": false,
"absolute_value": null,
"multiply_value": null,
"multiply_attribute": null,
"relative_value": 2,
"relative_attribute": null,
"new_value": null,
"description": ""
}
],
"skill": [],
"transaction_log": [],
"character": [
{
"id": 1,
"name": "Sabetha",
"hit_points": 10,
"temp_hit_points": 0,
"_max_hit_points": 10,
"_armor_class": 10,
"_strength": 10,
"_dexterity": 10,
"_constitution": 10,
"_intelligence": 14,
"_wisdom": 10,
"_charisma": 10,
"_vision": null,
"exhaustion": 0,
"ancestry_id": 2,
"slug": "nMnWu"
},
{
"id": 2,
"name": "Bob",
"hit_points": 10,
"temp_hit_points": 0,
"_max_hit_points": 10,
"_armor_class": 10,
"_strength": 10,
"_dexterity": 10,
"_constitution": 10,
"_intelligence": 10,
"_wisdom": 10,
"_charisma": 10,
"_vision": null,
"exhaustion": 0,
"ancestry_id": 1,
"slug": "PjPdM"
}
],
"class_attribute_map": [],
"class_attribute_option": [],
"class_skill_map": [],
"modifier_map": [
{
"id": 1,
"modifier_id": 1,
"primary_table_name": "ancestry_trait",
"primary_table_id": 1
},
{
"id": 2,
"modifier_id": 2,
"primary_table_name": "ancestry",
"primary_table_id": 2
},
{
"id": 3,
"modifier_id": 3,
"primary_table_name": "ancestry",
"primary_table_id": 2
}
],
"trait_map": [
{
"id": 1,
"ancestry_id": 2,
"ancestry_trait_id": 1,
"level": 1
}
],
"character_class_attribute_map": [],
"character_skill_map": [],
"class_map": [
{
"id": 1,
"character_id": 1,
"character_class_id": 1,
"level": 2
},
{
"id": 2,
"character_id": 1,
"character_class_id": 2,
"level": 3
}
],
"hit_die": [
{
"id": 1,
"character_id": 1,
"character_class_id": 1,
"spent": false
},
{
"id": 2,
"character_id": 1,
"character_class_id": 1,
"spent": false
},
{
"id": 3,
"character_id": 1,
"character_class_id": 2,
"spent": false
},
{
"id": 4,
"character_id": 1,
"character_class_id": 2,
"spent": false
},
{
"id": 5,
"character_id": 1,
"character_class_id": 2,
"spent": false
}
]
}

View File

@ -1,29 +0,0 @@
import json
from pathlib import Path
from ttfrog.db import schema
from ttfrog.db.manager import db
DATA_PATH = Path(__file__).parent
def load(data: str = ""):
db.metadata.drop_all(bind=db.engine)
db.init()
with db.transaction():
if not data:
data = (DATA_PATH / "bootstrap.json").read_text()
db.load(json.loads(data))
tiefling = db.Ancestry.filter_by(name="tiefling").one()
human = db.Ancestry.filter_by(name="human").one()
fighter = db.CharacterClass.filter_by(name="fighter").one()
rogue = db.CharacterClass.filter_by(name="rogue").one()
sabetha = schema.Character("Sabetha", ancestry=tiefling, _intelligence=14)
sabetha.add_class(fighter, level=2)
sabetha.add_class(rogue, level=3)
bob = schema.Character("Bob", ancestry=human)
# persist all the records we've created
db.add_or_update([sabetha, bob])

View File

@ -4,15 +4,16 @@ import json
import os import os
from contextlib import contextmanager from contextlib import contextmanager
from functools import cached_property from functools import cached_property
from sqlite3 import IntegrityError
import transaction import transaction
from pyramid_sqlalchemy.meta import Session from pyramid_sqlalchemy.meta import Session
from sqlalchemy import create_engine, event, insert from sqlalchemy import create_engine, event
from ttfrog.db import schema import ttfrog.db.schema
from ttfrog.path import database from ttfrog.path import database
assert ttfrog.db.schema
class AlchemyEncoder(json.JSONEncoder): class AlchemyEncoder(json.JSONEncoder):
def default(self, obj): def default(self, obj):
@ -41,7 +42,7 @@ class SQLDatabaseManager:
@cached_property @cached_property
def metadata(self): def metadata(self):
return schema.BaseObject.metadata return ttfrog.db.schema.BaseObject.metadata
@cached_property @cached_property
def tables(self): def tables(self):
@ -86,18 +87,8 @@ class SQLDatabaseManager:
results[table_name] = [dict(row._mapping) for row in self.query(table).all()] results[table_name] = [dict(row._mapping) for row in self.query(table).all()]
return json.dumps(results, indent=2, cls=AlchemyEncoder) return json.dumps(results, indent=2, cls=AlchemyEncoder)
def load(self, data: dict):
for table_name, rows in data.items():
table = self.tables.get(table_name, None)
if table is None:
raise IntegrityError(f"Table {table_name} not found in database.")
if not rows:
continue
query = insert(table), rows
self.session.execute(*query)
def __getattr__(self, name: str): def __getattr__(self, name: str):
return self.query(getattr(schema, name)) return self.query(getattr(ttfrog.db.schema, name))
db = SQLDatabaseManager() db = SQLDatabaseManager()

View File

@ -1,6 +1,5 @@
from .character import * from .character import *
from .classes import * from .classes import *
from .constants import *
from .log import * from .log import *
from .modifiers import * from .modifiers import *
from .skill import * from .skill import *

View File

@ -6,7 +6,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject, SlugMixin from ttfrog.db.base import BaseObject, SlugMixin
from ttfrog.db.schema.classes import CharacterClass, ClassAttribute from ttfrog.db.schema.classes import CharacterClass, ClassAttribute
from ttfrog.db.schema.constants import Conditions, DamageType, Defenses
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
from ttfrog.db.schema.skill import Skill from ttfrog.db.schema.skill import Skill
@ -213,7 +212,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
) )
_vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True}) _vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
exhaustion: Mapped[int] = mapped_column(nullable=False, default=0, info={"min": 0, "max": 5})
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan") class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
class_list = association_proxy("class_map", "id", creator=class_map_creator) class_list = association_proxy("class_map", "id", creator=class_map_creator)
@ -274,10 +272,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
def traits(self): def traits(self):
return self.ancestry.traits return self.ancestry.traits
@property
def initiative(self):
return self._apply_modifiers("initiative", self.dexterity.bonus)
@property @property
def speed(self): def speed(self):
return self._apply_modifiers("speed", self.ancestry.speed) return self._apply_modifiers("speed", self.ancestry.speed)
@ -300,7 +294,7 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
@property @property
def vision_in_darkness(self): def vision_in_darkness(self):
return self._apply_modifiers("vision_in_darkness", self.vision if self.vision is not None else 0) return self.apply_modifiers("vision_in_darkness", self.vision if self.vision is not None else 0)
@property @property
def level(self): def level(self):
@ -320,26 +314,23 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
return None return None
return mapping[0] return mapping[0]
def immune(self, damage_type: DamageType): def immune(self, damage_type: str, magical: bool = False):
return self.defense(damage_type) == Defenses.immune return self.defense(damage_type, magical) == 'immune'
def resistant(self, damage_type: DamageType): def resistant(self, damage_type: str, magical: bool = False):
return self.defense(damage_type) == Defenses.resistant.value return self.defense(damage_type, magical) == 'resistant'
def vulnerable(self, damage_type: DamageType): def vulnerable(self, damage_type: str, magical: bool = False):
return self.defense(damage_type) == Defenses.vulnerable return self.defense(damage_type, magical) == 'vulnerable'
def absorbs(self, damage_type: DamageType): def absorbs(self, damage_type: str, magical: bool = False):
return self.defense(damage_type) == Defenses.absorbs return self.defense(damage_type, magical) == 'absorbs'
def conditions(self): def defense(self, damage_type: str, magical: bool = False):
return [self._apply_modifiers(f"conditions.{name}") for name in Conditions] attr_name = damage_type
if magical:
def condition(self, condition_name: str): attr_name = f"magical_{attr_name}"
return self._apply_modifiers(f"conditions.{condition_name}", False) return self._apply_modifiers(f"defenses.{attr_name}", None)
def defense(self, damage_type: DamageType):
return self._apply_modifiers(damage_type, None)
def check_modifier(self, skill: Skill, save: bool = False): def check_modifier(self, skill: Skill, save: bool = False):
# if the skill is not assigned, but we have modifiers, apply them to zero. # if the skill is not assigned, but we have modifiers, apply them to zero.
@ -391,18 +382,18 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
self.add_skill(skill, proficient=True, character_class=newclass) self.add_skill(skill, proficient=True, character_class=newclass)
# add hit dice # add hit dice
existing = len([die for die in self._hit_dice if die.character_class_id == newclass.id]) existing = len(self.hit_dice[newclass.name])
for lvl in range(level - existing): for lvl in range(level - existing):
self._hit_dice.append(HitDie(character_id=self.id, character_class_id=newclass.id)) self._hit_dice.append(HitDie(character_id=self.id, character_class_id=newclass.id))
def remove_class(self, target): def remove_class(self, target):
self.class_map = [m for m in self.class_map if m.character_class != target]
for mapping in self.character_class_attribute_map: for mapping in self.character_class_attribute_map:
if mapping.character_class == target: if mapping.character_class == target:
self.remove_class_attribute(mapping.class_attribute) self.remove_class_attribute(mapping.class_attribute)
for skill in target.skills: for skill in target.skills:
self.remove_skill(skill, proficient=True, expert=False, character_class=target) self.remove_skill(skill, proficient=True, expert=False, character_class=target)
self._hit_dice = [die for die in self._hit_dice if die.character_class != target] self._hit_dice = [die for die in self._hit_dice if die.character_class != target]
self.class_map = [m for m in self.class_map if m.character_class != target]
def remove_class_attribute(self, attribute): def remove_class_attribute(self, attribute):
self.character_class_attribute_map = [ self.character_class_attribute_map = [
@ -431,8 +422,8 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
return True return True
def add_skill(self, skill, proficient=False, expert=False, character_class=None): def add_skill(self, skill, proficient=False, expert=False, character_class=None):
# if not self.id: if not self.id:
# raise Exception("Cannot add a skill before the character has been persisted.") raise Exception("Cannot add a skill before the character has been persisted.")
skillmap = None skillmap = None
exists = False exists = False
if skill in self.skills: if skill in self.skills:
@ -483,15 +474,15 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
def apply_healing(self, value: int): def apply_healing(self, value: int):
self.hit_points = min(self.hit_points + value, self._max_hit_points) self.hit_points = min(self.hit_points + value, self._max_hit_points)
def apply_damage(self, value: int, damage_type: DamageType): def apply_damage(self, value: int, damage_type: str, magical=False):
total = value total = value
if self.absorbs(damage_type): if self.absorbs(damage_type, magical):
return self.apply_healing(total) return self.apply_healing(total)
if self.immune(damage_type): if self.immune(damage_type, magical):
return return
if self.resistant(damage_type): if self.resistant(damage_type, magical):
total = int(value / 2) total = int(value / 2)
elif self.vulnerable(damage_type): elif self.vulnerable(damage_type, magical):
total = value * 2 total = value * 2
if total <= self.temp_hit_points: if total <= self.temp_hit_points:

View File

@ -1,52 +0,0 @@
from enum import StrEnum, auto
class Conditions(StrEnum):
blinded = auto()
charmed = auto()
deafened = auto()
frightened = auto()
grappled = auto()
incapacitated = auto()
invisible = auto()
paralyzed = auto()
petrified = auto()
poisoned = auto()
prone = auto()
restrained = auto()
stunned = auto()
unconscious = auto()
dead = auto()
class DamageType(StrEnum):
piercing = auto()
slashing = auto()
bludgeoning = auto()
fire = auto()
cold = auto()
lightning = auto()
thunder = auto()
acid = auto()
poison = auto()
radiant = auto()
necrotic = auto()
psychic = auto()
force = auto()
magical = auto()
magical_piercing = auto()
magical_slashing = auto()
magical_bludgeoning = auto()
silvered_piercing = auto()
silvered_slashing = auto()
silvered_bludgeoning = auto()
adamantium_piercing = auto()
adamantium_slashing = auto()
adamantium_bludgeoning = auto()
class Defenses(StrEnum):
vulnerable = auto()
resistant = auto()
immune = auto()
absorbs = auto()

View File

@ -6,7 +6,6 @@ import pytest
from ttfrog.db import schema from ttfrog.db import schema
from ttfrog.db.manager import db as _db from ttfrog.db.manager import db as _db
from ttfrog.db.schema.constants import DamageType, Defenses
FIXTURE_PATH = Path(__file__).parent / "fixtures" FIXTURE_PATH = Path(__file__).parent / "fixtures"
@ -50,12 +49,14 @@ def bootstrap(db):
darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120)) darkvision.add_modifier(schema.Modifier("Darkvision", target="vision_in_darkness", absolute_value=120))
tiefling.add_trait(darkvision) tiefling.add_trait(darkvision)
# resistant to fire # resistant to both magical and non-magical sources of fire
infernal_origin = schema.AncestryTrait("Infernal Origin") infernal_origin = schema.AncestryTrait("Infernal Origin")
infernal_origin.add_modifier(schema.Modifier("Infernal Origin", target="defenses.fire", new_value="resistant"))
infernal_origin.add_modifier( infernal_origin.add_modifier(
schema.Modifier("Infernal Origin", target=DamageType.fire, new_value=Defenses.resistant) schema.Modifier("Infernal Origin", target="defenses.magical_fire", new_value="resistant")
) )
tiefling.add_trait(infernal_origin) tiefling.add_trait(infernal_origin)
db.add_or_update(tiefling)
dragonborn = schema.Ancestry("dragonborn") dragonborn = schema.Ancestry("dragonborn")
dragonborn.add_trait(darkvision) dragonborn.add_trait(darkvision)

View File

@ -1,23 +0,0 @@
import json
from ttfrog.db.bootstrap import loader
def test_dump_load(db, bootstrap):
# dump the bootstrapped data
data = json.loads(db.dump())
# clear the database and reinitialize
db.metadata.drop_all(bind=db.engine)
db.init()
# load the dump
db.load(data)
# dump again and compare to the initial data
assert data == json.loads(db.dump())
def test_loader(db, bootstrap):
loader.load(db.dump())
assert len(db.Ancestry.all()) > 0

View File

@ -1,7 +1,6 @@
import json import json
from ttfrog.db import schema from ttfrog.db import schema
from ttfrog.db.schema.constants import DamageType, Defenses
def test_manage_character(db, bootstrap): def test_manage_character(db, bootstrap):
@ -97,14 +96,6 @@ def test_manage_character(db, bootstrap):
assert char.check_modifier(athletics) == char.proficiency_bonus + char.strength.bonus == 3 assert char.check_modifier(athletics) == char.proficiency_bonus + char.strength.bonus == 3
assert char.check_modifier(acrobatics) == char.proficiency_bonus + char.dexterity.bonus == 3 assert char.check_modifier(acrobatics) == char.proficiency_bonus + char.dexterity.bonus == 3
# assert dexterity bonus apply to initiative
char._dexterity = 17
assert char.dexterity.bonus == 3
assert char.initiative == char.dexterity.bonus == 3
char.add_modifier(schema.Modifier("+1 initiative", target="initiative", relative_value=1))
assert char.initiative == 4
char._dexterity = 10
# multiclass # multiclass
char.add_class(rogue, level=1) char.add_class(rogue, level=1)
db.add_or_update(char) db.add_or_update(char)
@ -288,39 +279,45 @@ def test_defenses(db, bootstrap):
with db.transaction(): with db.transaction():
tiefling = db.Ancestry.filter_by(name="tiefling").one() tiefling = db.Ancestry.filter_by(name="tiefling").one()
carl = schema.Character(name="Carl", ancestry=tiefling) carl = schema.Character(name="Carl", ancestry=tiefling)
assert carl.resistant(DamageType.fire) assert carl.resistant("fire", magical=False)
carl.apply_damage(5, DamageType.fire) assert carl.resistant("fire", magical=True)
carl.apply_damage(5, "fire", magical=True)
assert carl.hit_points == 8 # half damage assert carl.hit_points == 8 # half damage
immunity = [ immunity = [
schema.Modifier("Fire Immunity", target=DamageType.fire, new_value=Defenses.immune), schema.Modifier("Fire Immunity", target="defenses.fire", new_value="immune"),
schema.Modifier("Fire Immunity", target="defenses.magical_fire", new_value="immune")
] ]
for i in immunity: for i in immunity:
carl.add_modifier(i) carl.add_modifier(i)
assert carl.immune(DamageType.fire) assert carl.immune("fire")
carl.apply_damage(5, DamageType.fire) carl.apply_damage(5, "fire", magical=True)
carl.apply_damage(5, "fire", magical=False)
assert carl.hit_points == 8 # no damage assert carl.hit_points == 8 # no damage
vulnerability = [ vulnerability = [
schema.Modifier("Fire Vulnerability", target=DamageType.fire, new_value=Defenses.vulnerable), schema.Modifier("Fire Vulnerability", target="defenses.fire", new_value="vulnerable"),
schema.Modifier("Fire Vulnerability", target="defenses.magical_fire", new_value="vulnerable")
] ]
for i in vulnerability: for i in vulnerability:
carl.add_modifier(i) carl.add_modifier(i)
assert carl.vulnerable(DamageType.fire) assert carl.vulnerable("fire")
assert not carl.immune(DamageType.fire) assert not carl.immune("fire")
carl.apply_damage(2, DamageType.fire) carl.apply_damage(2, "fire", magical=True)
assert carl.hit_points == 4 # double damage assert carl.hit_points == 4 # double damage
absorbs = [schema.Modifier("Absorbs Non-Magical Fire", target=DamageType.fire, new_value=Defenses.absorbs)] absorbs = [
schema.Modifier("Absorbs Non-Magical Fire", target="defenses.fire", new_value="absorbs"),
]
carl.add_modifier(absorbs[0]) carl.add_modifier(absorbs[0])
carl.apply_damage(20, DamageType.fire) carl.apply_damage(20, "fire", magical=False)
assert carl.hit_points == carl._max_hit_points == 10 assert carl.hit_points == carl._max_hit_points == 10
for i in immunity + vulnerability + absorbs: for i in immunity + vulnerability + absorbs:
carl.remove_modifier(i) carl.remove_modifier(i)
carl.apply_damage(5, DamageType.fire) carl.apply_damage(5, "fire", magical=True)
assert carl.resistant(DamageType.fire) assert carl.resistant("fire")
assert not carl.immune(DamageType.fire) assert not carl.immune("fire")
assert not carl.vulnerable(DamageType.fire) assert not carl.vulnerable("fire")
assert not carl.absorbs(DamageType.fire) assert not carl.absorbs("fire")
assert carl.hit_points == 8 # half damage assert carl.hit_points == 8 # half damage