Source code for opend6_tools.character.features

"""
OpenD6 Character and Creature Features for the DSL.


Example
-------

>>> from random import seed
>>> seed(42)

>>> from opend6_tools.character import *
>>> human = Character(
...     occupation="Default", race="Human"
... )
>>> human.physique.dice
3*D
>>> human.extranormal.dice
0*D
>>> human.budget_check(CharacterBudget.NORMAL)
{'Attributes': '18D out of 18D', 'Skills': 'Nothing out of 7D', 'Options': 'Nothing'}
>>> human.budget
{'Attributes': 18*D, 'Skills': 0*D, 'Options': 0*D}

>>> w = CharacterWriter_Literal()
>>> print(w.report(human))
<BLANKLINE>
..  rubric:: OpenD6 Fantasy
<BLANKLINE>
<BLANKLINE>
+--------------------------------------------------+
| Character Name: ________________________________ |
+--------------------------------------------------+
| Occupation: Default                              |
+-------------------------+------------------------+
| Race: Human             | Gender: ______________ |
+----------------+--------+-------+----------------+
| Age: ________  | Height: ______ | Weight: ______ |
+----------------+----------------+----------------+
| Physical Description ___________________________ |
+--------------------------------------------------+
<BLANKLINE>
<BLANKLINE>
::
<BLANKLINE>
    +----------------------+----------------------+----------------------+
    | Agility           3D | Intellect         3D |                      |
    +----------------------+----------------------+----------------------+
    | acrobatics           | cultures             |**Advantages**:       |
    | climbing             | devices              |**Disadvantages**:    |
    | combat               | healing              |**Special Abilities**:|
    | contortion           | navigation           |**Equipment**:        |
    | dodge                | reading/writing      |**Description**:      |
    | fighting             | scholar              |                      |
    | flying               | speaking             |                      |
    | jumping              | trading              |                      |
    | melee combat         | traps                |                      |
    | riding               | ____________________ |                      |
    | stealth              | ____________________ |                      |
    | ____________________ | ____________________ |                      |
    | ____________________ | ____________________ |                      |
    +----------------------+----------------------+                      |
    | Coordination      3D | Acumen            3D |                      |
    +----------------------+----------------------+                      |
    | charioteering        | artist               |                      |
    | lockpicking          | crafting             |                      |
    | marksmanship         | disguise             |                      |
    | pilotry              | gambling             |                      |
    | sleight of hand      | hide                 |                      |
    | throwing             | investigation        |                      |
    | ____________________ | know-how             |                      |
    | ____________________ | search               |                      |
    | ____________________ | streetwise           |                      |
    | ____________________ | survival             |                      |
    | ____________________ | tracking             |                      |
    | ____________________ | ____________________ |                      |
    | ____________________ | ____________________ |                      |
    +----------------------+----------------------+                      |
    | Physique          3D | Charisma          3D |                      |
    +----------------------+----------------------+                      |
    | lifting              | animal handling      |                      |
    | running              | bluff                |                      |
    | stamina              | charm                |                      |
    | swimming             | command              |                      |
    | ____________________ | intimidation         |                      |
    | ____________________ | mettle               |                      |
    | ____________________ | persuasion           |                      |
    | ____________________ | ____________________ |                      |
    | ____________________ | ____________________ |                      |
    | ____________________ | ____________________ |                      |
    | ____________________ | ____________________ |                      |
    | ____________________ | ____________________ |                      |
    | ____________________ | ____________________ |                      |
    +----------------------+----------------------+----------------------+
    | Magic       ________ |                      |                      |
    +----------------------+----------------------+----------------------+
    | alteration           | Str Damage        2D | Body Points 28       |
    | apportation          | Move              10 | [ ] Stunned   17-21  |
    | conjuration          | Fate Pts      1      | [ ] Wounded   11-16  |
    | divination           | Character Pts 5      | [ ] Severe    6-10   |
    | ____________________ | Funds 3D             | [ ] Incapac'd 3-5    |
    | ____________________ | Silver 180           | [ ] Mortal    1-2    |
    |                      |                      | [ ] Dead             |
    +----------------------+----------------------+----------------------+
<BLANKLINE>

Top-Level Things
----------------

..  autoclass:: Character
    :members:
    :undoc-members:
    :member-order:  bysource

..  autoclass:: Creature
    :members:
    :undoc-members:
    :member-order:  bysource

..  autoclass:: Sword
    :members:
    :undoc-members:
    :member-order:  bysource


..  autoclass:: OptionList
    :members:

..  autoclass:: CharacterOption
    :members:

..  autoclass:: CharacterBudget
    :members:

Attributes
----------------------

..  autoclass:: Attribute
    :members:

..  autoclass:: Acumen
    :show-inheritance:
    :members:

..  autoclass:: Charisma
    :show-inheritance:
    :members:

..  autoclass:: Intellect
    :show-inheritance:
    :members:

..  autoclass:: Agility
    :show-inheritance:
    :members:

..  autoclass:: Coordination
    :show-inheritance:
    :members:

..  autoclass:: Physique
    :show-inheritance:
    :members:

..  autoclass:: Magic
    :show-inheritance:
    :members:

..  autoclass:: Miracles
    :show-inheritance:
    :members:


Options: Disadvantage
--------------------------------------------------

..  autoclass:: Disadvantage
    :show-inheritance:
    :members:

..  autoclass:: AchillesHeel
    :show-inheritance:

..  autoclass:: AdvantageFlaw
    :show-inheritance:

..  autoclass:: MinorStigma
    :show-inheritance:

..  autoclass:: Age
    :show-inheritance:

..  autoclass:: BadLuck
    :show-inheritance:

..  autoclass:: BurnOut
    :show-inheritance:

..  autoclass:: CulturalUnfamiliarity
    :show-inheritance:

..  autoclass:: Debt
    :show-inheritance:

..  autoclass:: Devotion
    :show-inheritance:

..  autoclass:: Employed
    :show-inheritance:

..  autoclass:: Enemy
    :show-inheritance:

..  autoclass:: Hindrance
    :show-inheritance:

..  autoclass:: Infamy
    :show-inheritance:

..  autoclass:: LanguageProblems
    :show-inheritance:

..  autoclass:: LearningProblems
    :show-inheritance:

..  autoclass:: Poverty
    :show-inheritance:

..  autoclass:: Prejudice
    :show-inheritance:

..  autoclass:: Price
    :show-inheritance:

..  autoclass:: Quirk
    :show-inheritance:

..  autoclass:: ReducedAttribute
    :show-inheritance:

Options: Advantage
--------------------------------------------------

..  autoclass:: Advantage
    :show-inheritance:
    :members:

..  autoclass:: Authority
    :show-inheritance:

..  autoclass:: Contacts
    :show-inheritance:

..  autoclass:: Cultures
    :show-inheritance:

..  autoclass:: Equipment
    :show-inheritance:

..  autoclass:: Fame
    :show-inheritance:

..  autoclass:: Patron
    :show-inheritance:

..  autoclass:: Size
    :show-inheritance:

..  autoclass:: TrademarkSpecialization
    :show-inheritance:

..  autoclass:: Wealth
    :show-inheritance:

Options: Special Ability
--------------------------------------------------

..  autoclass:: SpecialAbility
    :show-inheritance:
    :members:

..  autoclass:: AcceleratedHealing
    :show-inheritance:

..  autoclass:: Ambidextrous
    :show-inheritance:

..  autoclass:: AnimalControl
    :show-inheritance:

..  autoclass:: ArmorDefeatingAttack
    :show-inheritance:

..  autoclass:: AtmosphericTolerance
    :show-inheritance:

..  autoclass:: AttackResistance
    :show-inheritance:

..  autoclass:: AttributeScramble
    :show-inheritance:

..  autoclass:: Blur
    :show-inheritance:

..  autoclass:: CombatSense
    :show-inheritance:

..  autoclass:: Confusion
    :show-inheritance:

..  autoclass:: Darkness
    :show-inheritance:

..  autoclass:: Elasticity
    :show-inheritance:

..  autoclass:: Endurance
    :show-inheritance:

..  autoclass:: EnhancedSense
    :show-inheritance:

..  autoclass:: EnvironmentalResistance
    :show-inheritance:

..  autoclass:: ExtraBodyPart
    :show-inheritance:

..  autoclass:: ExtraSense
    :show-inheritance:

..  autoclass:: FastReactions
    :show-inheritance:

..  autoclass:: Fear
    :show-inheritance:

..  autoclass:: Flight
    :show-inheritance:

..  autoclass:: GliderWings
    :show-inheritance:

..  autoclass:: Hardiness
    :show-inheritance:

..  autoclass:: Hypermovement
    :show-inheritance:

..  autoclass:: Immortality
    :show-inheritance:

..  autoclass:: Immunity
    :show-inheritance:

..  autoclass:: IncreasedAttribute
    :show-inheritance:

..  autoclass:: InfravisionUltravision
    :show-inheritance:

..  autoclass:: Intangibility
    :show-inheritance:

..  autoclass:: Invisibility
    :show-inheritance:

..  autoclass:: IronWill
    :show-inheritance:

..  autoclass:: LifeDrain
    :show-inheritance:

..  autoclass:: Longevity
    :show-inheritance:

..  autoclass:: LuckGood
    :show-inheritance:

..  autoclass:: LuckGreat
    :show-inheritance:

..  autoclass:: MasterOfDisguise
    :show-inheritance:

..  autoclass:: MultipleAbilities
    :show-inheritance:

..  autoclass:: NaturalArmor
    :show-inheritance:

..  autoclass:: NaturalHandWeapon
    :show-inheritance:

..  autoclass:: NaturalMagick
    :show-inheritance:

..  autoclass:: NaturalRangedWeapon
    :show-inheritance:

..  autoclass:: Omnivorous
    :show-inheritance:

..  autoclass:: ParalyzingTouch
    :show-inheritance:

..  autoclass:: PossessionLimited
    :show-inheritance:

..  autoclass:: PossessionFull
    :show-inheritance:

..  autoclass:: QuickStudy
    :show-inheritance:

..  autoclass:: SenseOfDirection
    :show-inheritance:

..  autoclass:: Shapeshifting
    :show-inheritance:

..  autoclass:: Silence
    :show-inheritance:

..  autoclass:: SkillBonus
    :show-inheritance:

..  autoclass:: SkillMinimum
    :show-inheritance:

..  autoclass:: Teleportation
    :show-inheritance:

..  autoclass:: Transmutation
    :show-inheritance:

..  autoclass:: UncannyAptitude
    :show-inheritance:

..  autoclass:: Ventriloquism
    :show-inheritance:

..  autoclass:: WaterBreathing
    :show-inheritance:

..  autoclass:: YouthfulAppearance
    :show-inheritance:

..  autoclass:: NaturalAbility
    :members:


"""

from dataclasses import dataclass, field
from enum import Enum
from functools import partial
import re
import sys
from textwrap import dedent
from types import SimpleNamespace
from typing import Annotated, Any, ClassVar, cast

from humre import *  # noqa: F403  # type: ignore[import-untyped]

from ..dice import DieCode, D
from ..magic.spells import (
    DISADVANTAGE_RULES,
    SPECIALABILITY_RULES,
    parse_rule_table,
)

__all__ = [
    "Character",
    "Creature",
    "Sword",
    # Base definitions
    "D",
    "DieCode",
    "R1",
    "R2",
    "R3",
    "R4",
    "R5",
    "R6",
    # Attributes
    "Attribute",
    "Agility",
    "Intellect",
    "Coordination",
    "Acumen",
    "Physique",
    "Charisma",
    "Miracles",
    "Magic",
    # Other Features
    "Advantage",
    "Disadvantage",
    "SpecialAbility",
    "NaturalAbility",
    "OptionList",  # Use instead of [] or list()
    "NoteList",
    "AchillesHeel",  # type: ignore
    "AdvantageFlaw",  # type: ignore
    "MinorStigma",
    "Age",  # type: ignore
    "BadLuck",  # type: ignore
    "BurnOut",  # type: ignore
    "CulturalUnfamiliarity",  # type: ignore
    "Debt",  # type: ignore
    "Devotion",  # type: ignore
    "Employed",  # type: ignore
    "Enemy",  # type: ignore
    "Hindrance",  # type: ignore
    "Infamy",  # type: ignore
    "LanguageProblems",  # type: ignore
    "LearningProblems",  # type: ignore
    "Poverty",  # type: ignore
    "Prejudice",  # type: ignore
    "Price",  # type: ignore
    "Quirk",  # type: ignore
    "ReducedAttribute",  # type: ignore
    "Authority",  # type: ignore
    "Contacts",  # type: ignore
    "Cultures",  # type: ignore
    "Equipment",  # type: ignore
    "Fame",  # type: ignore
    "Patron",  # type: ignore
    "Size",  # type: ignore
    "TrademarkSpecialization",  # type: ignore
    "Wealth",  # type: ignore
    "AcceleratedHealing",  # type: ignore
    "Ambidextrous",  # type: ignore
    "AnimalControl",  # type: ignore
    "ArmorDefeatingAttack",  # type: ignore
    "AtmosphericTolerance",  # type: ignore
    "AttackResistance",  # type: ignore
    "AttributeScramble",  # type: ignore
    "Blur",  # type: ignore
    "CombatSense",  # type: ignore
    "Confusion",  # type: ignore
    "Darkness",  # type: ignore
    "Elasticity",  # type: ignore
    "Endurance",  # type: ignore
    "EnhancedSense",  # type: ignore
    "EnvironmentalResistance",  # type: ignore
    "ExtraBodyPart",  # type: ignore
    "ExtraSense",  # type: ignore
    "FastReactions",  # type: ignore
    "Fear",  # type: ignore
    "Flight",  # type: ignore
    "GliderWings",  # type: ignore
    "Hardiness",  # type: ignore
    "Hypermovement",  # type: ignore
    "Immortality",  # type: ignore
    "Immunity",  # type: ignore
    "IncreasedAttribute",  # type: ignore
    "InfravisionUltravision",  # type: ignore
    "Intangibility",  # type: ignore
    "Invisibility",  # type: ignore
    "IronWill",  # type: ignore
    "LifeDrain",  # type: ignore
    "Longevity",  # type: ignore
    "LuckGood",  # type: ignore
    "LuckGreat",  # type: ignore
    "MasterOfDisguise",  # type: ignore
    "MultipleAbilities",  # type: ignore
    "NaturalArmor",  # type: ignore
    "NaturalHandWeapon",
    "PainTolerance",
    "NaturalMagick",  # type: ignore
    "NaturalRangedWeapon",  # type: ignore
    "Omnivorous",  # type: ignore
    "ParalyzingTouch",  # type: ignore
    "PossessionLimited",  # type: ignore
    "PossessionFull",  # type: ignore
    "QuickStudy",  # type: ignore
    "SenseOfDirection",  # type: ignore
    "Shapeshifting",  # type: ignore
    "Silence",  # type: ignore
    "SkillBonus",  # type: ignore
    "SkillMinimum",  # type: ignore
    "Teleportation",  # type: ignore
    "Transmutation",  # type: ignore
    "UncannyAptitude",  # type: ignore
    "Ventriloquism",  # type: ignore
    "WaterBreathing",  # type: ignore
    "YouthfulAppearance",  # type: ignore
    # Main App
    "CharacterBudget",
    # Some handy names
    "sys",
    "Annotated",
    "partial",
]

## Part I, Foundational Definitions (not used)

## Part II, Units (not used)

## Part III, character DSL, Attributes and Skills

R1 = 1
R2 = 2
R3 = 3
R4 = 4
R5 = 5
R6 = 6


[docs] class Attribute: """Map any explicitly-named skills to the DieCode for that skill. So far, the upper limit is about 12 distinct skills for an Attribute. A skill name is either a simple word or a more complicated word:specialization. Reserved names: - ``'name'`` - ``'dice'`` (for the attribute measure) - ``'keys'``, ``'values'``, ``'items'`` from the ``dict`` class. .. notes: Generally, we want to use ``character.attribute.skill``, but we also need to use ``character.attribute.get('skill:specialty')`` and ``character.attributes['skill:specialty']``. For character sheets, all skills are shown, even if there's no overriding dice allocation. (In a narrow, technical sense, the dice value for a missing skill comes from the overall attribute.) For character detail (in .RST), only the overriding skill values need to be shown. Example >>> from opend6_tools.character import * >>> class Extranormal(Attribute): ... SKILL_NAMES = ("alteration", "apportation", "conjuration", "divination",) >>> ex = Extranormal(DieCode(3, 2), {"divination": DieCode(4)}) >>> ex.name 'Extranormal' >>> ex.alteration is None True >>> ex.divination is None False >>> str(ex['divination']) '4D' """ SKILL_NAMES: ClassVar[list[str]] dice: DieCode skills: dict[str, DieCode] def __init__( self, base: DieCode | None = None, skills: dict[str, DieCode | int] | None = None, ) -> None: """ Defines the Attribute's collection of skills. :param base: The base DieCode for this Attribute. :param skills: A mapping from Skill name to Die Code. """ defaults = {s: 0 for s in self.SKILL_NAMES} self.dice = DieCode(1) if base is None else base # Could be DieCode(0) self.skills = {} for name, code in (skills or defaults).items(): match code: case DieCode() as die_code: self.skills[name] = die_code case int(): self.skills[name] = DieCode(*divmod(code, 3)) case _: # pragma: no cover raise ValueError(f"must be DieCode or int, not {type(code)}") self.rows = sorted(self.skills.keys()) def __repr__(self) -> str: return f"{self.__class__.__name__}({self.dice!r}, {self.skills!r})"
[docs] def row(self, n: int, width: int = 20) -> str: """ Return a given row of text from the column in a tabular character sheet. :param n: row number :param width: text width of the row to pad with spaces. :returns: string with skill name and skill value. """ if n < len(self.rows): skill_name = self.rows[n] die_code = self.skills[skill_name] skill_value = f"{die_code}" if die_code else "" hspan = " " * (width - len(skill_name) - len(skill_value)) return f"{skill_name}{hspan}{skill_value}" else: return ""
@property def name(self) -> str: return self.__class__.__name__ def __getattr__(self, name: str) -> Any: """Wraps skills: gets a value of a skill referenced by ``c.attribute.name``""" if name in self.skills: return self.skills[name] elif hasattr(self.skills, name): return getattr(self.skills, name) else: return None def __getitem__(self, name: str) -> DieCode: """Wraps skills: gets a value of a skill referenced by ``attribute['name']``.""" return cast(DieCode, self.skills.get(name))
[docs] class Acumen(Attribute): """The Acumen attribute. See :external:ref:`fantasy.character_basics`. Rules: *Acumen*: Your character's mental quickness, creativity, and attention to detail. """ SKILL_NAMES = [ "artist", "crafting", "disguise", "gambling", "hide", "investigation", "know-how", "search", "streetwise", "survival", "tracking", ]
[docs] class Charisma(Attribute): """The Charisma attribute. See :external:ref:`fantasy.character_basics`. Rules: *Charisma*: A gauge of emotional strength, physical attractiveness, and personality. """ SKILL_NAMES = [ "animal handling", "bluff", "charm", "command", "intimidation", "mettle", "persuasion", ]
[docs] class Intellect(Attribute): """The Intellect attribute. See :external:ref:`fantasy.character_basics`. Rules: *Intellect*: A measure of strength of memory and ability to learn. """ SKILL_NAMES = [ "cultures", "devices", "healing", "navigation", "reading/writing", "scholar", "speaking", "trading", "traps", ]
[docs] class Agility(Attribute): """The Agility attribute. See :external:ref:`fantasy.character_basics`. Rules: *Agility*: An indication of balance, limberness, quickness, and full-body motor abilities. """ SKILL_NAMES = [ "acrobatics", "climbing", "contortion", "dodge", "fighting", "flying", "jumping", "melee combat", "combat", "riding", "stealth", ]
[docs] class Coordination(Attribute): """The Coordination attribute. See :external:ref:`fantasy.character_basics`. Rules: *Coordination*: A quantification of hand-eye coordination and fine motor abilities. """ SKILL_NAMES = [ "charioteering", "lockpicking", "marksmanship", "pilotry", "sleight of hand", "throwing", ]
[docs] class Physique(Attribute): """The Physique attribute. See :external:ref:`fantasy.character_basics`. Rules: *Physique*: An estimation of physical power and ability to resist damage. """ SKILL_NAMES = [ "lifting", "running", "stamina", "swimming", ]
[docs] class Magic(Attribute): """The Extranormal magic attribute. See :external:ref:`fantasy.character_basics`. Rules: *Extranormal*: An assessment of your character's extraordinary abilities, which could include magic, miracles, or other extranormal talents. It is often listed by its type, rather than by the term "Extranormal." """ SKILL_NAMES = ["alteration", "apportation", "conjuration", "divination"]
[docs] class Miracles(Attribute): """The Extranormal miracles attribute. See :external:ref:`fantasy.character_basics`. Rules: *Extranormal*: An assessment of your character's extraordinary abilities, which could include magic, miracles, or other extranormal talents. It is often listed by its type, rather than by the term "Extranormal." """ SKILL_NAMES = ["divination", "favor", "strife"]
def classify(text: str) -> str: """Make a Python class name from a word or phrase. This is lossy and not fully reversible. >>> classify("Possession: Limited") 'PossessionLimited' >>> classify("Infravision/Ultravision") 'InfravisionUltravision' """ words = re.split(r"\W+", text.strip().lower()) return "".join(map(lambda w: w.title(), words))
[docs] class CharacterOption: """Advantages, Disadvantages, Special Abilities, and (for creatures) Natural Abilities.""" name: str = "Character Option" rank_cost: int = 1 # Unit is Die (3 pips) def __init__(self, rank: int, notes: str = "") -> None: self.rank = rank self.notes = notes def __repr__(self) -> str: return f"{self.__class__.__name__}(rank={self.rank!r}, notes={self.notes!r})" def __str__(self) -> str: return f"{self.name} (R{self.rank}), {self.notes}" def __eq__(self, other: Any) -> bool: match other: case CharacterOption() as option: return self.rank == option.rank and self.notes == option.notes case _: # pragma: no cover return NotImplemented @property def value(self) -> int: return self.rank * self.rank_cost
[docs] class Disadvantage(CharacterOption): """Base class of all disadvantages. >>> disad = Disadvantage(2, "some details") >>> str(disad) 'Disadvantage (R2), some details' """ name = "Disadvantage" pass
# Parse the source text table to create the types for label, rank_cost in parse_rule_table(DISADVANTAGE_RULES): class_name = classify(label) globals()[class_name] = type(class_name, (Disadvantage,), {"name": label}) # Added in unpublished Magic rules, not in the Fantasy rules table being parsed.
[docs] class MinorStigma(Disadvantage): name = "Minor Stigma"
# class AchillesHeel(Disadvantage): # name = "Achilles' Heel" # # # class AdvantageFlaw(Disadvantage): # name = "Advantage Flaw" # # # class MinorStigma(Disadvantage): # name = "Minor Stigma" # # # class Age(Disadvantage): # name = "Age" # # # class BadLuck(Disadvantage): # name = "Bad Luck" # # # class BurnOut(Disadvantage): # name = "Burn-out" # # # class CulturalUnfamiliarity(Disadvantage): # name = "Cultural Unfamiliarity" # # # class Debt(Disadvantage): # name = "Debt" # # # class Devotion(Disadvantage): # name = "Devotion" # # # class Employed(Disadvantage): # name = "Employed" # # # class Enemy(Disadvantage): # name = "Enemy" # # # class Hindrance(Disadvantage): # name = "Hindrance" # # # class Infamy(Disadvantage): # name = "Infamy" # # # class LanguageProblems(Disadvantage): # name = "Language Problems" # # # class LearningProblems(Disadvantage): # name = "Learning Problems" # # # class Poverty(Disadvantage): # name = "Poverty" # # # class Prejudice(Disadvantage): # name = "Prejudice" # # # class Price(Disadvantage): # name = "Price" # # # class Quirk(Disadvantage): # name = "Quirk" # # # class ReducedAttribute(Disadvantage): # name = "Reduced Attribute" ADVANTAGE_RULES = dedent("""\ Authority (R1, R2, R3) Contacts (R1, R2, R3, R4) Cultures (R1, R2, R3, R4) Equipment (R1, R2, R3, R4) Fame (R1, R2, R3) Patron (R1, R2, R3) Size (R1 or more) Trademark Specialization (R1) Wealth (R1 or more) """)
[docs] class Advantage(CharacterOption): """Base class of all advantages.""" name = "Advantage" pass
# Parse the source text table to create the types for label, rank_cost in parse_rule_table(ADVANTAGE_RULES): class_name = classify(label) globals()[class_name] = type(class_name, (Advantage,), {"name": label}) # class Authority(Advantage): # name = "Authority" # # # class Contacts(Advantage): # name = "Contacts" # # # class Cultures(Advantage): # name = "Cultures" # # # class Equipment(Advantage): # name = "Equipment" # # # class Fame(Advantage): # name = "Fame" # # # class Patron(Advantage): # name = "Patron" # # # class Size(Advantage): # name = "Size" # # # class TrademarkSpecialization(Advantage): # name = "Trademark Specialization" # # # class Wealth(Advantage): # name = "Wealth"
[docs] class SpecialAbility(CharacterOption): """Base class of all Special Abilities.""" name = "SpecialAbility" pass
# Parse the source text table to create the types for label, rank_cost in parse_rule_table(SPECIALABILITY_RULES): class_name = classify(label) number, *words = rank_cost.split() # type: ignore globals()[class_name] = type( class_name, (SpecialAbility,), {"name": label, "rank_cost": int(number)} ) # An alias introduced in the previous version
[docs] class NaturalHandWeapon(NaturalHandToHandWeapon): # type: ignore pass
# A new special ability, introduced in Fantasy Locations class PainTolerance(SpecialAbility): name = "Pain Tolerance" rank_cost = 2 # class AcceleratedHealing(SpecialAbility): # name = "Accelerated Healing" # rank_cost = 3 # # # class Ambidextrous(SpecialAbility): # name = "Ambidextrous" # rank_cost = 2 # # # class AnimalControl(SpecialAbility): # name = "Animal Control" # rank_cost = 3 # # # class ArmorDefeatingAttack(SpecialAbility): # name = "Armor-Defeating Attack" # rank_cost = 2 # # # class AtmosphericTolerance(SpecialAbility): # name = "Atmospheric Tolerance" # rank_cost = 2 # # # class AttackResistance(SpecialAbility): # name = "Attack Resistance" # rank_cost = 2 # # # class AttributeScramble(SpecialAbility): # name = "Attribute Scramble" # rank_cost = 4 # # # class Blur(SpecialAbility): # name = "Blur" # rank_cost = 3 # # # class CombatSense(SpecialAbility): # name = "Combat Sense" # rank_cost = 3 # # # class Confusion(SpecialAbility): # name = "Confusion" # rank_cost = 4 # # # class Darkness(SpecialAbility): # name = "Darkness" # rank_cost = 3 # # # class Elasticity(SpecialAbility): # name = "Elasticity" # rank_cost = 1 # # # class Endurance(SpecialAbility): # name = "Endurance" # rank_cost = 1 # # # class EnhancedSense(SpecialAbility): # name = "Enhanced Sense" # rank_cost = 3 # # # class EnvironmentalResistance(SpecialAbility): # name = "Environmental Resistance" # rank_cost = 1 # # # class ExtraBodyPart(SpecialAbility): # name = "Extra Body Part" # rank_cost = 0 # # # class ExtraSense(SpecialAbility): # name = "Extra Sense" # rank_cost = 1 # # # class FastReactions(SpecialAbility): # name = "Fast Reactions" # rank_cost = 3 # # # class Fear(SpecialAbility): # name = "Fear" # rank_cost = 2 # # # class Flight(SpecialAbility): # name = "Flight" # rank_cost = 6 # # # class GliderWings(SpecialAbility): # name = "Glider Wings" # rank_cost = 3 # # # class Hardiness(SpecialAbility): # name = "Hardiness" # rank_cost = 1 # # # class Hypermovement(SpecialAbility): # name = "Hypermovement" # rank_cost = 1 # # # class Immortality(SpecialAbility): # name = "Immortality" # rank_cost = 7 # # # class Immunity(SpecialAbility): # name = "Immunity" # rank_cost = 1 # # # class IncreasedAttribute(SpecialAbility): # name = "Increased Attribute" # rank_cost = 2 # # # class InfravisionUltravision(SpecialAbility): # name = "Infravision/Ultravision" # rank_cost = 1 # # # class Intangibility(SpecialAbility): # name = "Intangibility" # rank_cost = 5 # # # class Invisibility(SpecialAbility): # name = "Invisibility" # rank_cost = 3 # # # class IronWill(SpecialAbility): # name = "Iron Will" # rank_cost = 2 # # # class LifeDrain(SpecialAbility): # name = "Life Drain" # rank_cost = 5 # # # class Longevity(SpecialAbility): # name = "Longevity" # rank_cost = 3 # # # class LuckGood(SpecialAbility): # name = "Luck: Good" # rank_cost = 2 # # # class LuckGreat(SpecialAbility): # name = "Luck: Great" # rank_cost = 3 # # # class MasterOfDisguise(SpecialAbility): # name = "Master of Disguise" # rank_cost = 3 # # # class MultipleAbilities(SpecialAbility): # name = "Multiple Abilities" # rank_cost = 1 # # # class NaturalArmor(SpecialAbility): # name = "Natural Armor" # rank_cost = 3 # # # class NaturalHandWeapon(SpecialAbility): # name = "Natural Hand-to-Hand Weapon" # rank_cost = 2 # # # class NaturalMagick(SpecialAbility): # name = "Natural Magick" # rank_cost = 5 # or more # # # class NaturalRangedWeapon(SpecialAbility): # name = "Natural Ranged Weapon" # rank_cost = 3 # # # class Omnivorous(SpecialAbility): # name = "Omnivorous" # rank_cost = 2 # # # class ParalyzingTouch(SpecialAbility): # name = "Paralyzing Touch" # rank_cost = 4 # # # class PossessionLimited(SpecialAbility): # name = "Possession: Limited" # rank_cost = 8 # # # class PossessionFull(SpecialAbility): # name = "Possession: Full" # rank_cost = 10 # # # class QuickStudy(SpecialAbility): # name = "Quick Study" # rank_cost = 3 # # # class SenseOfDirection(SpecialAbility): # name = "Sense of Direction" # rank_cost = 2 # # # class Shapeshifting(SpecialAbility): # name = "Shapeshifting" # rank_cost = 3 # # # class Silence(SpecialAbility): # name = "Silence" # rank_cost = 3 # # # class SkillBonus(SpecialAbility): # name = "Skill Bonus" # rank_cost = 1 # # # class SkillMinimum(SpecialAbility): # name = "Skill Minimum" # rank_cost = 4 # # # class Teleportation(SpecialAbility): # name = "Teleportation" # rank_cost = 3 # # # class Transmutation(SpecialAbility): # name = "Transmutation" # rank_cost = 5 # # # class UncannyAptitude(SpecialAbility): # name = "Uncanny Aptitude" # rank_cost = 3 # # # class Ventriloquism(SpecialAbility): # name = "Ventriloquism" # rank_cost = 3 # # # class WaterBreathing(SpecialAbility): # name = "Water Breathing" # rank_cost = 2 # # # class YouthfulAppearance(SpecialAbility): # name = "Youthful Appearance" # rank_cost = 1
[docs] class NaturalAbility(CharacterOption): name = "NaturalAbility" rank_cost: int = 0 def __init__(self, notes: str) -> None: self.notes = notes def __repr__(self) -> str: return f"{self.__class__.__name__}(notes={self.notes!r})" def __str__(self) -> str: return f"{self.notes}"
[docs] class OptionList(list[CharacterOption]): """Cooperates with :func:`pprint.pformat` to produce pretty content""" def __init__(self, *args: CharacterOption) -> None: super().__init__(args) def __str__(self) -> str: items = "; ".join(map(str, self)) return items def __repr__(self) -> str: items = ", ".join(map(repr, self)) return f"OptionList({items})"
class NoteList(list[str]): """ A collection of notes that can be exploded by line (like an ``OptionList``), or collapsed into a blob of text. """ def __init__(self, *args: str) -> None: if len(args) == 1 and isinstance(args[0], str): # Parse a ";" separated string of items. items = (arg.strip() for arg in args[0].split(";")) super().__init__(items) else: super().__init__(args) def __str__(self) -> str: items = "; ".join(map(str, self)) return items def __repr__(self) -> str: items = ", ".join(map(repr, self)) return f"NoteList({items})" ## Part IV, Character DSL
[docs] class CharacterBudget(Enum): """Sample budgets for normal and experienced characters.""" NO_BUDGET = None NORMAL = SimpleNamespace( attributes=DieCode(18), skills=DieCode(7), options=DieCode(0) ) EXPERIENCED = SimpleNamespace( attributes=DieCode(18), skills=DieCode(22), options=DieCode(0) )
[docs] @dataclass class Character: """Character definition. Attributes have 18 dice = 54 pips Skills have 7 dice = 21 pips """ # Identification Block name: str = "" occupation: str = "" race: str = "" gender: str = "" age: str = "" height: str | None = "" # Use None to force a computation. weight: str | None = "" physical_description: str = "" # Features agility: Attribute = field(default=Agility(3 * D)) intellect: Attribute = field(default=Intellect(3 * D)) coordination: Attribute = field(default=Coordination(3 * D)) acumen: Attribute = field(default=Acumen(3 * D)) physique: Attribute = field(default=Physique(3 * D)) charisma: Attribute = field(default=Charisma(3 * D)) extranormal: Attribute = field(default=Magic(DieCode(0))) # Or Miracles advantages: OptionList | list = field(default_factory=OptionList) disadvantages: OptionList | list = field(default_factory=OptionList) special_abilities: OptionList | list = field(default_factory=OptionList) # Special case for simple characters shown in the text. description: str = "" # Extension used for Creatures in Kingdom of the East. realm: str = "Human realm" # Features with derivable values and defined default values move: int | str = 10 strength_damage: DieCode | None = None # Based on physique or physique.lifting body: int | DieCode | None = None # Based on physique roll + 20 wounds: dict[str, str] = field(default_factory=dict) # Set by _wounds_table. funds: DieCode | None = ( None # Based on charisma, intellect, and intellect.trading* # built by funds_roll ) silver: int | None = None # Based on funds. # Given as part of the character fate_points: int | None = None character_points: int | None = None # Some page 2 sections... equipment: NoteList = field(default_factory=NoteList) #: type, notes; ... armor: NoteList = field(default_factory=NoteList) #: type, AV, notes; ... weapons: NoteList = field( default_factory=NoteList ) #: type, dmg, range (S, M, L), ammo; ... spells: NoteList = field(default_factory=NoteList) #: name, difficulty, notes; ... personality: str = "" objectives: str = "" native_language: str = "" other_notes: str = "" @property def attributes(self) -> DieCode: values = [ getattr(self, name).dice for name in ( "agility", "intellect", "coordination", "acumen", "physique", "charisma", "extranormal", ) ] return sum(values, DieCode(0)) @property def skills(self) -> DieCode: values = [ skill_value for name in ( "agility", "intellect", "coordination", "acumen", "physique", "charisma", "extranormal", ) for skill_value in getattr(self, name).values() ] return sum(values, DieCode(0)) @property def options(self) -> DieCode: debit_values = [ option.value for name in ( "advantages", "special_abilities", ) for option in getattr(self, name) ] credit_values = [ option.value for name in ("disadvantages",) for option in getattr(self, name) ] debit = DieCode.from_pips(sum(debit_values)) credit = DieCode.from_pips(sum(credit_values)) return debit - credit @property def budget(self) -> dict[str, DieCode]: return { "Attributes": self.attributes, "Skills": self.skills, "Options": self.options, }
[docs] def budget_check( self, check: CharacterBudget = CharacterBudget.NO_BUDGET ) -> dict[str, str]: if check.value: return { "Attributes": f"{self.attributes or 'Nothing'} out of {check.value.attributes}", "Skills": f"{self.skills or 'Nothing'} out of {check.value.skills}", "Options": f"{self.options or 'Nothing'}", } return {name: f"{value or 'Nothing'}" for name, value in self.budget.items()}
def __post_init__(self) -> None: """Set derived or default values.""" if not self.strength_damage: lifting_dice = self.physique.lifting or self.physique.dice self.strength_damage = DieCode(int(lifting_dice.d / 2 + 0.5)) if self.body and isinstance(self.body, DieCode): self.body = self.body.roll().total elif self.body is None and self.physique.dice: self.body = self.physique.dice.roll().total + 20 elif self.body == 0: # Special case for using wound-levels. pass else: # Ordinary case -- body was provided and is non-zero. pass if not self.wounds: # Depends on body being zero or non-zero self.wounds = self._wounds_table() if self.funds is None: # Depends on charisma, intellect, and trading skill self.funds = self.funds_roll() if self.silver is None: # Depends on funds self.silver = self.funds.d * 60 if self.fate_points is None: self.fate_points = 1 if self.character_points is None: self.character_points = 5 if self.height is None or self.weight is None: # Depends on Physique and gender # Implicitly, it also depends on the symmetry parameter h, w = self.height_weight_roll() if self.height is None: self.height = h if self.weight is None: self.weight = w # Syntactic sugar for OptionList and NoteList attributes. # TODO: Look for fields with type OptionList for nm in "advantages", "disadvantages", "special_abilities": attr_value = getattr(self, nm) if attr_value and not isinstance(attr_value, OptionList): setattr(self, nm, OptionList(*attr_value)) # TODO: Look for fields with type NoteList for nm in "equipment", "armor", "weapons", "spells": attr_value = getattr(self, nm) if attr_value and isinstance(attr_value, str): setattr(self, nm, NoteList(attr_value)) elif attr_value and not isinstance(attr_value, NoteList): setattr(self, nm, NoteList(*attr_value)) def _wounds_table(self) -> dict[str, str]: top_relative = { "Stunned": 0.8, "Wounded": 0.6, "Severe": 0.4, "Incapacitated": 0.2, "Mortal": 0.1, } if self.body and isinstance(self.body, DieCode): # pragma: no cover # Should be handled by ``__post_init__()``. self.body = self.body.roll().total if self.body: # Non-zero, the typical case. top_points = { name: int(0.5 + cast(int, self.body) * level) for name, level in top_relative.items() } # Prevent Overlaps. level = "Mortal" start = 1 end = top_points[level] if end == start: end += 1 ranges = [ (level, (start, end)), ] for new_level in ("Incapacitated", "Severe", "Wounded", "Stunned"): last_level, (start, end) = ranges[-1] new_start = end new_end = top_points[new_level] while new_start >= new_end: new_end += 1 ranges.append((new_level, (new_start, new_end))) range_map = dict(ranges) return { r: f"{l}" if l + 1 == h else f"{l}-{h - 1}" for r, (l, h) in range_map.items() } else: # body=None (or zero, which seems unlikely) return {r: f"{l:.0%}" for r, l in top_relative.items()}
[docs] def funds_roll(self) -> DieCode: """ From the Character Basics rules, Funds section. 'All characters start with a base of 3 in Funds. Use the accompanying table to adjust this number.' """ def adj(dice: DieCode) -> int: if dice.d == 1: return -1 elif dice.d >= 4: return +1 else: return 0 funds = [3, adj(self.charisma.dice), adj(self.intellect.dice)] trading_dice = [ self.intellect[s].d for s in self.intellect.keys() if s.startswith("trading") ] funds.append(1 if sum(trading_dice) >= 8 else 0) return DieCode(max(1, sum(funds)))
[docs] def height_weight_roll(self, symmetric: bool = False) -> tuple[str, str]: """ Some notes... - Female characters - Mass (kg): 48 + 3 × Physique roll - Height (cm): 155 + Physique roll - Male characters - Mass (kg): 70 + 2 × Physique roll - Height (cm): 169 + Physique roll :param symmetric: If True, one roll is used to limit variability. If False, separate height and weight rolls are used. The default is False, no height-weight symmetry. :return: tuple of height and weight strings. """ if "female" in self.gender.lower(): mass_base, mass_scale, height_base = 48, 3, 155 else: mass_base, mass_scale, height_base = 78, 2, 169 if symmetric: roll = self.physique.dice.roll().total weight = mass_base + mass_scale * roll height = height_base + roll else: weight = mass_base + mass_scale * self.physique.dice.roll().total height = height_base + self.physique.dice.roll().total return f"{height}cm", f"{weight}kg"
[docs] @dataclass class Creature(Character): natural_abilities: OptionList | list = field(default_factory=OptionList) note: str = "" def __post_init__(self) -> None: super().__post_init__() if self.natural_abilities and not isinstance( self.natural_abilities, OptionList ): self.natural_abilities = OptionList(*self.natural_abilities)
[docs] @dataclass class Sword(Character): natural_abilities: OptionList | list = field(default_factory=OptionList) note: str = "" def __post_init__(self) -> None: super().__post_init__() if self.natural_abilities and not isinstance( self.natural_abilities, OptionList ): self.natural_abilities = OptionList(*self.natural_abilities)
type CharacterDict = dict[str, Character]