summaryrefslogtreecommitdiff
path: root/character.py
diff options
context:
space:
mode:
authorSupremist <sergkarv@gmail.com>2025-10-28 17:24:24 +0200
committerSupremist <sergkarv@gmail.com>2025-10-28 17:24:24 +0200
commit0cc1078b97891cab00f724e6b2a1a8287b293d3d (patch)
tree79d1712567bbd6b0e34cacb5ae388d5d7163cc01 /character.py
parenta792c35f6ee3b37ecb6fe1692f81574509728cae (diff)
downloadpf2e_calc-0cc1078b97891cab00f724e6b2a1a8287b293d3d.tar.xz
pf2e_calc-0cc1078b97891cab00f724e6b2a1a8287b293d3d.zip
Add character leveling calculations
Diffstat (limited to 'character.py')
-rw-r--r--character.py231
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
+
+