summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--character.py231
-rw-r--r--leveling/fundamental_runes.json11
-rw-r--r--leveling/rogue.json18
-rw-r--r--main.py12
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}
+}
diff --git a/main.py b/main.py
index 38fbd59..fd90005 100644
--- a/main.py
+++ b/main.py
@@ -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__':