diff options
| author | Supremist <sergkarv@gmail.com> | 2025-10-28 17:24:24 +0200 |
|---|---|---|
| committer | Supremist <sergkarv@gmail.com> | 2025-10-28 17:24:24 +0200 |
| commit | 0cc1078b97891cab00f724e6b2a1a8287b293d3d (patch) | |
| tree | 79d1712567bbd6b0e34cacb5ae388d5d7163cc01 /character.py | |
| parent | a792c35f6ee3b37ecb6fe1692f81574509728cae (diff) | |
| download | pf2e_calc-0cc1078b97891cab00f724e6b2a1a8287b293d3d.tar.xz pf2e_calc-0cc1078b97891cab00f724e6b2a1a8287b293d3d.zip | |
Add character leveling calculations
Diffstat (limited to 'character.py')
| -rw-r--r-- | character.py | 231 |
1 files changed, 231 insertions, 0 deletions
diff --git a/character.py b/character.py new file mode 100644 index 0000000..7c814bd --- /dev/null +++ b/character.py @@ -0,0 +1,231 @@ +import re +import json +from abc import ABC, abstractmethod + +from dataclasses import dataclass, asdict + + +_skill_ability = { +# "classDC" + "perception": "wis", + "fortitude": "con", + "reflex": "dex", + "will": "wis", + "acrobatics": "dex", + "arcana": "int", + "athletics": "str", + "crafting": "int", + "deception": "cha", + "diplomacy": "cha", + "intimidation": "cha", + "medicine": "wis", + "nature": "wis", + "occultism": "int", + "performance": "cha", + "religion": "wis", + "society": "int", + "stealth": "dex", + "survival": "wis", + "thievery": "dex", + "lore1": "int", + "lore2": "int" +} + +class LevelingPlan(ABC): + @abstractmethod + def build(self, level: int): + pass + + +class FeaturesPlan(LevelingPlan, dict): + @classmethod + def load(cls, name): + with open(f"leveling/{name}.json", "r", encoding="utf8") as js_file: + return cls({int(k):v for k,v in json.load(js_file).items()}) + + def build(self, level: int): + res = dict() + # should be 0, but just in case of negative levels + for lvl in range(-5, level+1): + res.update(self.get(lvl, dict())) + return res + + def merge(self, other): + for lvl, other_val in other.items(): + if lvl in self: + val = self[lvl] + for key in (val.keys() & other_val.keys()): + assert other_val[key] == val[key] + val.update(other_val) + else: + self[lvl] = other_val + + +class Abilities(dict): + field_names = ["str", "dex", "int", "wis", "cha", "con"] + + def __init__(self, data=None, **kwargs): + super().__init__({field: 10 for field in Abilities.field_names}) + if data: + self.update(data) + if kwargs: + self.update(kwargs) + + @staticmethod + def ability_boost_factor(value): + if value < 18: + return 2 + return 1 + + @staticmethod + def ability_modifier(value): + return (value - 10) // 2 + + def apply_change(self, ability_name, step_direction=1): + ability = self[ability_name] + ability += step_direction*Abilities.ability_boost_factor(ability) + self[ability_name] = ability + + def apply_changes(self, changes, step_direction=1): + for change in changes: + self.apply_change(change.lower(), step_direction) + + def modifier(self, ability_name): + return Abilities.ability_modifier(self[ability_name]) + +class AbilitiesPlan(LevelingPlan): + def __init__(self, data) -> None: + super().__init__() + boosts = data.pop("mapLevelledBoosts") + self.levelBoosts = { int(level): values for level, values in boosts.items() } + self.initialFlaws = dict() + for flaw in ["ancestryFlaws"]: + self.initialFlaws[flaw] = data.pop(flaw) + self.initialBoosts = data + + def build(self, level: int): + res = Abilities() + for flaw in self.initialFlaws.values(): + res.apply_changes(flaw, -1) + if level <= 0: + return res + for boost in self.initialBoosts.values(): + res.apply_changes(boost) + for lvl, boost in self.levelBoosts.items(): + if lvl <= level: + res.apply_changes(boost) + return res + +@dataclass +class Armor: + name: str + category: str = "light" + ac: int = 1 + dex_cap: int = 4 + check_penalty: int = -1 + speed_penalty: int = -5 + strength_req: int = 0 + +@dataclass +class Character: + data: dict + hp_bonus: int + hp_per_level: int + speed: int + level: int + abilities: Abilities + features: dict + armor: Armor + + def __init__(self, data): + self.data = data + self.hp_bonus = 10 + self.hp_per_level = 8 + self.speed = 25 + self.level = 1 + self.abilities = Abilities() + self.features = dict() + self.armor = Armor("Leather armor") + + def max_hp(self): + per_level = self.hp_per_level + self.abilities.modifier("con") + return self.hp_bonus + per_level * self.level + + def skill_ability(self, skill): + if skill == "classDC": + return self.data["keyability"] + return _skill_ability.get(skill) + + def proficiency_bonus(self, skill): + return self.features.get(skill, 0) + self.level + + def proficiency(self, skill): + # TODO + item bonuses + ability = self.skill_ability(skill) + ability = self.abilities.modifier(ability) if ability else 0 + return ability + self.proficiency_bonus(skill) + + def ac(self): + dex_bonus = min(self.armor.dex_cap, self.abilities.modifier("dex")) + prof = self.proficiency(self.armor.category) + return 10 + dex_bonus + prof + self.armor.ac + + def classDC(self): + return 10 + self.proficiency("classDC") + + def toJSON(self): + methods = ["classDC", "max_hp", "ac"] + obj = asdict(self) + for method in methods: + obj[method] = getattr(self, method)() + return json.dumps(obj, indent=2) + + +class CharacterPlan(LevelingPlan): + def __init__(self, data) -> None: + self.data = data + (self._class, self.subclass) = self.parse_subclass(data["class"]) + data["class"] = self._class + data["subclass"] = self.subclass + abilities = data["abilities"]["breakdown"] + self.attributes = data["attributes"] + self.cleanup_build_data(data) + self.abilities = AbilitiesPlan(abilities) + self.features = FeaturesPlan() + self.features.merge(FeaturesPlan.load(self._class)) + self.features.merge(FeaturesPlan.load("fundamental_runes")) + # TODO add skill increases to calculate proficiencies + + + @staticmethod + def cleanup_build_data(data): + duplicates = ["acTotal", "level", "xp", "abilities", "attributes", + "proficiencies"] + spells = ["rituals", "spellCasters", "focus", "focusPoints"] + equipment = ["money", "armor", "weapons", "equipment", "equipmentContainers"] + blacklist = duplicates + blacklist.extend(spells) + blacklist.extend(equipment) + for key in blacklist: + del data[key] + + @staticmethod + def parse_subclass(s: str): + match = re.fullmatch(r'\s*([^(]+)\s*\(\s*([^)]+)\s*\)\s*', s) + if match: + return match.group(1).strip().lower(), match.group(2).strip().lower() + return s, None + + + def build(self, level: int): + res = Character(self.data) + res.level = level + res.speed = self.attributes["speed"] + self.attributes["speedBonus"] + res.hp_bonus = self.attributes["bonushp"] + self.attributes["ancestryhp"] + res.hp_per_level = \ + self.attributes["classhp"] + self.attributes["bonushpPerLevel"] + res.abilities = self.abilities.build(level) + res.features = self.features.build(level) + return res + + |
