diff options
| -rw-r--r-- | character.py | 231 | ||||
| -rw-r--r-- | leveling/fundamental_runes.json | 11 | ||||
| -rw-r--r-- | leveling/rogue.json | 18 | ||||
| -rw-r--r-- | main.py | 12 |
4 files changed, 269 insertions, 3 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 + + diff --git a/leveling/fundamental_runes.json b/leveling/fundamental_runes.json new file mode 100644 index 0000000..a1cbef6 --- /dev/null +++ b/leveling/fundamental_runes.json @@ -0,0 +1,11 @@ +{ + "2" : {"weapon potency": 1}, + "4" : {"striking": 2}, + "5" : {"armor potency": 1}, + "10": {"weapon potency": 2}, + "11": {"armor potency": 2}, + "12": {"striking": 3}, + "16": {"weapon potency": 3}, + "18": {"armor potency": 3}, + "19": {"striking": 4} +} diff --git a/leveling/rogue.json b/leveling/rogue.json new file mode 100644 index 0000000..e368f8a --- /dev/null +++ b/leveling/rogue.json @@ -0,0 +1,18 @@ +{ + "1": { + "rogue's racket": true, "surprise attack": true, "sneak attack": "1d6", + "unarmed": 2, "simple": 2, "martial":2, + "unarmored": 2, "light": 2, + "fortitude": 2, "reflex": 4, "will": 4, "perception": 4, "classDC": 2 +}, + "3": {"deny advantage": true}, + "5": {"sneak attack": "2d6", "unarmed":4, "simple": 4, "martial": 4, "critical specialization": "off-guard"}, + "7": {"reflex": 6, "perception": 6, "weapon specialization": 1}, + "9": {"fortitude": 6, "debilitating strike": 1}, + "11": {"classDC": 4, "sneak attack": "3d6"}, + "13": {"reflex": 8, "perception": 8, "unarmored": 4, "light": 4, "unarmed": 6, "simple": 6, "martial": 6}, + "15": {"weapon specialization": 2, "debilitating strike": 2}, + "17": {"will": 6}, + "19": {"unarmored": 6, "light": 6}, + "20": {"master strike": true} +} @@ -1,6 +1,7 @@ import math from sys import base_exec_prefix from distribution import Distribution +from character import CharacterPlan import pb2e @@ -188,9 +189,14 @@ def test3(): show_stat("falcata", falcata.strike(bonus, dmg_bonus, ac, 0)) def main4(): - # char = pb2e.download_character(pfb_id=160180) - char = pb2e.open_character("quizrel2") - print(char) + #char_js = pb2e.download_character(pfb_id=160180) + char_js = pb2e.open_character("quizrel2") + plan = CharacterPlan(char_js) + char = plan.build(1) + print(char.toJSON()) + char = plan.build(5) + print(char.toJSON()) + if __name__ == '__main__': |
