dndkit 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dndkit/__init__.py +10 -0
- dndkit/__main__.py +6 -0
- dndkit/activity.py +68 -0
- dndkit/app.py +105 -0
- dndkit/config.py +104 -0
- dndkit/errors.py +48 -0
- dndkit/game/__init__.py +1 -0
- dndkit/game/areas.py +181 -0
- dndkit/game/battlefield.py +154 -0
- dndkit/game/bestiary.py +155 -0
- dndkit/game/characters.py +227 -0
- dndkit/game/combat.py +209 -0
- dndkit/game/combatants.py +188 -0
- dndkit/game/concentration.py +43 -0
- dndkit/game/dice.py +186 -0
- dndkit/game/effects.py +183 -0
- dndkit/game/grid.py +88 -0
- dndkit/game/journal.py +47 -0
- dndkit/game/movement.py +264 -0
- dndkit/game/pathing.py +153 -0
- dndkit/game/proficiencies.py +104 -0
- dndkit/game/progression.py +77 -0
- dndkit/game/readiness.py +214 -0
- dndkit/game/reference.py +123 -0
- dndkit/game/registry.py +172 -0
- dndkit/game/resources.py +96 -0
- dndkit/game/rests.py +98 -0
- dndkit/game/rules.py +196 -0
- dndkit/game/seating.py +71 -0
- dndkit/game/sight.py +142 -0
- dndkit/game/spellbook.py +97 -0
- dndkit/game/tables.py +216 -0
- dndkit/game/world.py +125 -0
- dndkit/models/__init__.py +69 -0
- dndkit/models/base.py +26 -0
- dndkit/models/battle.py +40 -0
- dndkit/models/bestiary.py +56 -0
- dndkit/models/campaign.py +209 -0
- dndkit/models/character.py +149 -0
- dndkit/models/world.py +62 -0
- dndkit/skills/__init__.py +14 -0
- dndkit/skills/catalog.py +113 -0
- dndkit/skills/dnd-battle-map/SKILL.md +78 -0
- dndkit/skills/dnd-game-master/SKILL.md +125 -0
- dndkit/skills/dnd-monsters/SKILL.md +85 -0
- dndkit/skills/extension.py +93 -0
- dndkit/storage/__init__.py +6 -0
- dndkit/storage/catalog.py +72 -0
- dndkit/storage/confirmations.py +54 -0
- dndkit/storage/files.py +55 -0
- dndkit/storage/sharing.py +67 -0
- dndkit/storage/store.py +278 -0
- dndkit/text.py +13 -0
- dndkit/tools/__init__.py +47 -0
- dndkit/tools/apps.py +47 -0
- dndkit/tools/battle.py +117 -0
- dndkit/tools/bestiary.py +121 -0
- dndkit/tools/campaign.py +208 -0
- dndkit/tools/characters.py +189 -0
- dndkit/tools/combat.py +169 -0
- dndkit/tools/deps.py +42 -0
- dndkit/tools/journal.py +102 -0
- dndkit/tools/locations.py +98 -0
- dndkit/tools/npcs.py +101 -0
- dndkit/tools/play.py +118 -0
- dndkit/tools/players.py +44 -0
- dndkit/tools/progression.py +41 -0
- dndkit/tools/quests.py +95 -0
- dndkit/tools/recorder.py +63 -0
- dndkit/tools/render.py +281 -0
- dndkit/tools/render_combat.py +121 -0
- dndkit/tools/render_map.py +141 -0
- dndkit/tools/resources.py +104 -0
- dndkit/tools/schema.py +47 -0
- dndkit/tools/screen.py +47 -0
- dndkit/tools/spells.py +67 -0
- dndkit/tools/utility.py +42 -0
- dndkit/ui/__init__.py +52 -0
- dndkit/ui/backstage.js +41 -0
- dndkit/ui/battlemap.js +70 -0
- dndkit/ui/character.html +23 -0
- dndkit/ui/combat.html +18 -0
- dndkit/ui/components.css +283 -0
- dndkit/ui/components.js +261 -0
- dndkit/ui/desk.js +91 -0
- dndkit/ui/dice.html +42 -0
- dndkit/ui/dicetray.js +99 -0
- dndkit/ui/gm.html +59 -0
- dndkit/ui/party.html +18 -0
- dndkit/ui/player.html +61 -0
- dndkit/ui/seating.html +18 -0
- dndkit/ui/shared.css +195 -0
- dndkit/ui/shared.js +242 -0
- dndkit/ui/sheet.js +75 -0
- dndkit/ui/table.html +41 -0
- dndkit/ui/words.js +120 -0
- dndkit/ui/world.js +56 -0
- dndkit/views/__init__.py +7 -0
- dndkit/views/backstage.py +49 -0
- dndkit/views/battle.py +92 -0
- dndkit/views/desk.py +76 -0
- dndkit/views/everyone.py +194 -0
- dndkit/views/public.py +89 -0
- dndkit/views/widgets.py +58 -0
- dndkit/views/world.py +70 -0
- dndkit/web/__init__.py +10 -0
- dndkit/web/access.py +136 -0
- dndkit/web/audiences.py +111 -0
- dndkit/web/changes.py +84 -0
- dndkit/web/feed.py +112 -0
- dndkit/web/routes.py +134 -0
- dndkit/web/server.py +131 -0
- dndkit-0.4.0.dist-info/METADATA +567 -0
- dndkit-0.4.0.dist-info/RECORD +118 -0
- dndkit-0.4.0.dist-info/WHEEL +5 -0
- dndkit-0.4.0.dist-info/entry_points.txt +2 -0
- dndkit-0.4.0.dist-info/licenses/LICENSE +22 -0
- dndkit-0.4.0.dist-info/top_level.txt +1 -0
dndkit/game/bestiary.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The bestiary (saved stat blocks) and planned encounters rated against the party."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from dndkit.errors import InvalidChangeError
|
|
8
|
+
from dndkit.game import battlefield, grid, registry, rules, seating
|
|
9
|
+
from dndkit.models import Battlefield, Campaign, Encounter, EncounterGroup, Monster
|
|
10
|
+
|
|
11
|
+
MONSTER_KIND = "monster"
|
|
12
|
+
ENCOUNTER_KIND = "encounter"
|
|
13
|
+
_NUMBER_SUFFIX = re.compile(r"\s+\d+$")
|
|
14
|
+
|
|
15
|
+
# What to do when the bestiary lacks a monster: a small model otherwise stops there.
|
|
16
|
+
NEW_MONSTER = " To add it: save_monster (in the dnd-monsters skill), with its book numbers."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_monster(campaign: Campaign, ref: str) -> Monster:
|
|
20
|
+
"""The bestiary entry *ref* names."""
|
|
21
|
+
return registry.find(campaign.monsters, ref, MONSTER_KIND, NEW_MONSTER)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def monster_for(campaign: Campaign, name: str) -> Monster | None:
|
|
25
|
+
"""The bestiary entry a combatant name stands for: "Goblin 2" is a Goblin."""
|
|
26
|
+
found = registry.find_exact(campaign.monsters, name)
|
|
27
|
+
return found or registry.find_exact(campaign.monsters, _NUMBER_SUFFIX.sub("", name))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def save_monster(campaign: Campaign, name: str, fields: Mapping[str, Any]) -> tuple[Monster, bool]:
|
|
31
|
+
"""Create or update a stat block; XP follows the challenge rating unless given.
|
|
32
|
+
|
|
33
|
+
Returns the monster and whether it is new.
|
|
34
|
+
"""
|
|
35
|
+
values = dict(fields)
|
|
36
|
+
challenge = values.get("challenge_rating")
|
|
37
|
+
if challenge is not None and values.get("xp") is None:
|
|
38
|
+
xp = rules.xp_for_challenge(challenge)
|
|
39
|
+
if xp is None:
|
|
40
|
+
raise InvalidChangeError(f"Unknown challenge rating '{challenge}'; use 0-30 or 1/8.")
|
|
41
|
+
values["xp"] = xp
|
|
42
|
+
values = {key: value for key, value in values.items() if value is not None}
|
|
43
|
+
existing = registry.find_exact(campaign.monsters, name)
|
|
44
|
+
if existing is not None:
|
|
45
|
+
return registry.update(campaign.monsters, existing.id, MONSTER_KIND, values), False
|
|
46
|
+
_fill_from_challenge(values)
|
|
47
|
+
if values.get("boss"): # a 5e boss is usually a legendary creature
|
|
48
|
+
values.setdefault("legendary_actions", 3)
|
|
49
|
+
values.setdefault("legendary_resistances", 3)
|
|
50
|
+
missing = [key for key in ("armor_class", "hit_points") if key not in values]
|
|
51
|
+
if missing:
|
|
52
|
+
known = registry.known_names(campaign.monsters.values())
|
|
53
|
+
raise InvalidChangeError(
|
|
54
|
+
f"A new monster needs {' and '.join(missing)}, or a challenge_rating to set them. "
|
|
55
|
+
f"To change one, give its full name: {known}."
|
|
56
|
+
)
|
|
57
|
+
return registry.add(
|
|
58
|
+
campaign.monsters, Monster(name=name.strip(), **values), MONSTER_KIND
|
|
59
|
+
), True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fill_from_challenge(values: dict[str, Any]) -> None:
|
|
63
|
+
"""What a new monster lacks, from its challenge rating (DMG ch. 9)."""
|
|
64
|
+
stats = rules.monster_stats(values.get("challenge_rating") or "")
|
|
65
|
+
if stats is None:
|
|
66
|
+
return
|
|
67
|
+
values.setdefault("armor_class", stats.armor_class)
|
|
68
|
+
values.setdefault("hit_points", stats.hit_points)
|
|
69
|
+
values.setdefault(
|
|
70
|
+
"actions",
|
|
71
|
+
f"Attack +{stats.attack_bonus}, about {stats.damage_per_round} damage a round "
|
|
72
|
+
f"(split between its attacks), save DC {stats.save_dc}.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _check_squares(
|
|
77
|
+
campaign: Campaign, field: Battlefield | None, groups: Sequence[Mapping[str, Any]]
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Planned squares must be on the encounter's map, off its walls, one creature each."""
|
|
80
|
+
planned = [(g["monster"], square) for g in groups for square in g.get("at") or []]
|
|
81
|
+
if not planned:
|
|
82
|
+
return
|
|
83
|
+
if field is None:
|
|
84
|
+
raise InvalidChangeError("Squares need a map: give the encounter a battlefield.")
|
|
85
|
+
taken = battlefield.wall_squares(field)
|
|
86
|
+
for group in groups:
|
|
87
|
+
if len(group.get("at") or []) > group["count"]:
|
|
88
|
+
raise InvalidChangeError(f"More squares than {group['monster']} monsters.")
|
|
89
|
+
for monster, square in planned:
|
|
90
|
+
size = find_monster(campaign, monster).size
|
|
91
|
+
held = set(grid.footprint(grid.parse(square, field), size))
|
|
92
|
+
if held & taken or any(not grid.inside(s, field) for s in held):
|
|
93
|
+
raise InvalidChangeError(f"{monster} cannot stand on {square.upper()}.")
|
|
94
|
+
taken |= held
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def walking_speed(monster: Monster) -> int | None:
|
|
98
|
+
"""The walking speed in a stat block's speed text ("30 ft., fly 60 ft." -> 30)."""
|
|
99
|
+
match = re.match(r"\s*(\d+)", monster.speed or "")
|
|
100
|
+
return int(match.group(1)) if match else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def find_encounter(campaign: Campaign, ref: str) -> Encounter:
|
|
104
|
+
"""The planned encounter *ref* names."""
|
|
105
|
+
return registry.find(campaign.encounters, ref, ENCOUNTER_KIND)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def plan_encounter(
|
|
109
|
+
campaign: Campaign,
|
|
110
|
+
name: str,
|
|
111
|
+
groups: Sequence[EncounterGroup] | None,
|
|
112
|
+
fields: Mapping[str, Any],
|
|
113
|
+
) -> tuple[Encounter, bool]:
|
|
114
|
+
"""Create or update an encounter; its monsters must be in the bestiary."""
|
|
115
|
+
values = {key: value for key, value in fields.items() if value is not None}
|
|
116
|
+
if groups is not None:
|
|
117
|
+
# One error naming every unknown monster, then the bestiary's own spelling.
|
|
118
|
+
registry.find_many(
|
|
119
|
+
campaign.monsters, [g.monster for g in groups], MONSTER_KIND, NEW_MONSTER
|
|
120
|
+
)
|
|
121
|
+
values["monsters"] = [
|
|
122
|
+
{**g.model_dump(), "monster": find_monster(campaign, g.monster).name} for g in groups
|
|
123
|
+
]
|
|
124
|
+
existing = registry.find_exact(campaign.encounters, name)
|
|
125
|
+
_check_squares(
|
|
126
|
+
campaign,
|
|
127
|
+
values.get("battlefield") or (existing.battlefield if existing else None),
|
|
128
|
+
values.get("monsters") or (existing.model_dump()["monsters"] if existing else []),
|
|
129
|
+
)
|
|
130
|
+
if existing is not None:
|
|
131
|
+
return registry.update(campaign.encounters, existing.id, ENCOUNTER_KIND, values), False
|
|
132
|
+
if not values.get("monsters"):
|
|
133
|
+
known = registry.known_names(campaign.encounters.values())
|
|
134
|
+
raise InvalidChangeError(
|
|
135
|
+
f"A new encounter needs its monsters. To change one, give its full name: {known}."
|
|
136
|
+
)
|
|
137
|
+
encounter = Encounter.model_validate({"name": name.strip(), **values})
|
|
138
|
+
return registry.add(campaign.encounters, encounter, ENCOUNTER_KIND), True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def difficulty(campaign: Campaign, encounter: Encounter) -> rules.Difficulty | None:
|
|
142
|
+
"""The encounter rated against the characters at the table; None without any.
|
|
143
|
+
|
|
144
|
+
Two players out of four make a medium fight a deadly one: absent players'
|
|
145
|
+
characters do not count, once there has been a roll call (``seating``).
|
|
146
|
+
"""
|
|
147
|
+
levels = [c.character_class.level for c in seating.present_characters(campaign)]
|
|
148
|
+
if not levels:
|
|
149
|
+
return None
|
|
150
|
+
xps = [
|
|
151
|
+
find_monster(campaign, group.monster).xp
|
|
152
|
+
for group in encounter.monsters
|
|
153
|
+
for _ in range(group.count)
|
|
154
|
+
]
|
|
155
|
+
return rules.encounter_difficulty(levels, xps)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Creating, changing and removing player characters."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from dndkit.errors import InvalidChangeError
|
|
9
|
+
from dndkit.game import proficiencies, registry, rules
|
|
10
|
+
from dndkit.models import ABILITIES, Campaign, Character, now
|
|
11
|
+
|
|
12
|
+
KIND = "character"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def find(campaign: Campaign, ref: str) -> Character:
|
|
16
|
+
"""The character *ref* names (name or ID)."""
|
|
17
|
+
return registry.find(campaign.characters, ref, KIND)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Spellcasting:
|
|
22
|
+
"""How a character casts: the ability, the spell attack bonus and the save DC."""
|
|
23
|
+
|
|
24
|
+
ability: str
|
|
25
|
+
attack: int
|
|
26
|
+
save_dc: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pick(campaign: Campaign, ref: str | None) -> Character:
|
|
30
|
+
"""The character *ref* names; without one, whose turn it is, or the only character."""
|
|
31
|
+
if ref:
|
|
32
|
+
return find(campaign, ref)
|
|
33
|
+
current = campaign.game_state.current_combatant
|
|
34
|
+
if current is not None and current.character_id in campaign.characters:
|
|
35
|
+
return campaign.characters[current.character_id]
|
|
36
|
+
if len(campaign.characters) == 1:
|
|
37
|
+
return next(iter(campaign.characters.values()))
|
|
38
|
+
names = registry.known_names(campaign.characters.values())
|
|
39
|
+
raise InvalidChangeError(f"Which character? Characters: {names}.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create(campaign: Campaign, character: Character) -> Character:
|
|
43
|
+
"""Add a character with what its class and level give: proficiency, hit dice, a
|
|
44
|
+
caster's slots and casting ability. The name must be free."""
|
|
45
|
+
cls = character.character_class
|
|
46
|
+
if character.spellcasting_ability is None:
|
|
47
|
+
character.spellcasting_ability = rules.casting_ability(cls.name)
|
|
48
|
+
die = rules.hit_die(cls.name)
|
|
49
|
+
character.character_class = cls.model_copy(update={"hit_dice": f"1d{die}"})
|
|
50
|
+
character.hit_dice_remaining = f"{cls.level}d{die}"
|
|
51
|
+
character.proficiency_bonus = rules.proficiency_bonus(cls.level)
|
|
52
|
+
if not character.saving_throw_proficiencies:
|
|
53
|
+
character.saving_throw_proficiencies = rules.class_saves(cls.name)
|
|
54
|
+
if not character.spell_slots:
|
|
55
|
+
character.spell_slots = rules.spell_slots_for(cls.name, cls.level) or {}
|
|
56
|
+
return registry.add(campaign.characters, character, KIND)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def level_edits(character: Character, level: int) -> dict[str, Any]:
|
|
60
|
+
"""What follows a level set by hand: proficiency, hit dice and spell slots (spent
|
|
61
|
+
ones stay spent). Hit points do not: ``level_up`` gains a level in full."""
|
|
62
|
+
cls = character.character_class
|
|
63
|
+
die = rules.hit_die(cls.name)
|
|
64
|
+
edits: dict[str, Any] = {
|
|
65
|
+
"character_class": {**cls.model_dump(), "level": level, "hit_dice": f"1d{die}"},
|
|
66
|
+
"proficiency_bonus": rules.proficiency_bonus(level),
|
|
67
|
+
"hit_dice_remaining": f"{level}d{die}",
|
|
68
|
+
}
|
|
69
|
+
slots = rules.spell_slots_for(cls.name, level)
|
|
70
|
+
if slots is not None:
|
|
71
|
+
edits["spell_slots"] = dict(slots)
|
|
72
|
+
edits["spell_slots_used"] = slots_still_used(character, slots)
|
|
73
|
+
return edits
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def hit_dice_left(character: Character) -> int:
|
|
77
|
+
"""How many Hit Dice are left ("3d8" -> 3); 0 if the sheet says something else."""
|
|
78
|
+
match = re.match(r"\s*(\d+)\s*d", character.hit_dice_remaining)
|
|
79
|
+
return int(match.group(1)) if match else 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def slots_still_used(character: Character, slots: Mapping[int, int]) -> dict[int, int]:
|
|
83
|
+
"""The spent slots that remain spent once the character has *slots*."""
|
|
84
|
+
used = character.spell_slots_used
|
|
85
|
+
return {lvl: min(count, slots[lvl]) for lvl, count in used.items() if lvl in slots}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def spellcasting(character: Character) -> Spellcasting | None:
|
|
89
|
+
"""Spell attack bonus and save DC (PHB ch. 10); None for a character who casts nothing."""
|
|
90
|
+
ability = rules.ability_name(character.spellcasting_ability) or rules.casting_ability(
|
|
91
|
+
character.character_class.name
|
|
92
|
+
)
|
|
93
|
+
if ability is None or ability not in character.abilities:
|
|
94
|
+
return None
|
|
95
|
+
bonus = character.proficiency_bonus + character.abilities[ability].mod
|
|
96
|
+
return Spellcasting(ability, bonus, 8 + bonus)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def initiative(character: Character) -> int:
|
|
100
|
+
"""The initiative bonus: the Dexterity modifier, and a bard's Jack of All Trades."""
|
|
101
|
+
dexterity = character.abilities.get("dexterity")
|
|
102
|
+
return (dexterity.mod if dexterity else 0) + proficiencies.jack_of_all_trades(character)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _skill_edits(character: Character, edits: Mapping[str, Any]) -> dict[str, list[str]]:
|
|
106
|
+
"""Skills and Expertise checked together: Expertise only in a proficient skill, and
|
|
107
|
+
a skill dropped from the list loses its Expertise."""
|
|
108
|
+
proficient = character.skill_proficiencies
|
|
109
|
+
checked: dict[str, list[str]] = {}
|
|
110
|
+
if "skill_proficiencies" in edits:
|
|
111
|
+
proficient = proficiencies.skills(edits["skill_proficiencies"])
|
|
112
|
+
checked["skill_proficiencies"] = proficient
|
|
113
|
+
if "skill_expertise" in edits:
|
|
114
|
+
checked["skill_expertise"] = proficiencies.expertise(edits["skill_expertise"], proficient)
|
|
115
|
+
else:
|
|
116
|
+
kept = {proficiencies.skill_name(name) for name in proficient}
|
|
117
|
+
checked["skill_expertise"] = [
|
|
118
|
+
s for s in character.skill_expertise if proficiencies.skill_name(s) in kept
|
|
119
|
+
]
|
|
120
|
+
return checked
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def remove(campaign: Campaign, ref: str) -> Character:
|
|
124
|
+
"""Remove a character from the campaign."""
|
|
125
|
+
return registry.remove(campaign.characters, ref, KIND)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def update(
|
|
129
|
+
campaign: Campaign,
|
|
130
|
+
ref: str,
|
|
131
|
+
changes: Mapping[str, Any],
|
|
132
|
+
abilities: Mapping[str, int],
|
|
133
|
+
) -> Character:
|
|
134
|
+
"""Apply sheet changes; ``class_level`` and ability scores go to their nested fields."""
|
|
135
|
+
character = find(campaign, ref)
|
|
136
|
+
edits = dict(changes)
|
|
137
|
+
level = edits.pop("class_level", None)
|
|
138
|
+
if level is not None:
|
|
139
|
+
edits.update(level_edits(character, level))
|
|
140
|
+
if edits.get("exhaustion") == 6: # PHB appendix A: death
|
|
141
|
+
edits.update(hit_points_current=0, death_saves_failure=3, stable=False)
|
|
142
|
+
if character.hit_points_current == 0 and edits.get("hit_points_current", 0) > 0:
|
|
143
|
+
# Back from 0 HP by hand (a spell that raises the dead): a fresh start.
|
|
144
|
+
edits.update(death_saves_success=0, death_saves_failure=0, stable=False)
|
|
145
|
+
edits.setdefault("conditions", [c for c in character.conditions if c != "unconscious"])
|
|
146
|
+
if "skill_proficiencies" in edits or "skill_expertise" in edits:
|
|
147
|
+
edits.update(_skill_edits(character, edits))
|
|
148
|
+
if "saving_throw_proficiencies" in edits:
|
|
149
|
+
edits["saving_throw_proficiencies"] = proficiencies.saves(
|
|
150
|
+
edits["saving_throw_proficiencies"]
|
|
151
|
+
)
|
|
152
|
+
if abilities:
|
|
153
|
+
unknown = sorted(set(abilities) - set(ABILITIES))
|
|
154
|
+
if unknown:
|
|
155
|
+
raise InvalidChangeError(f"Unknown abilities: {', '.join(unknown)}.")
|
|
156
|
+
scores = {name: score.model_dump() for name, score in character.abilities.items()}
|
|
157
|
+
scores.update({name: {"score": value} for name, value in abilities.items()})
|
|
158
|
+
edits["abilities"] = scores
|
|
159
|
+
_cap_hit_points(character, edits)
|
|
160
|
+
edits["updated_at"] = now()
|
|
161
|
+
return registry.update(campaign.characters, character.id, KIND, edits)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _cap_hit_points(character: Character, edits: dict[str, Any]) -> None:
|
|
165
|
+
"""Refuse current HP above the maximum; a lowered maximum (or exhaustion 4)
|
|
166
|
+
lowers current HP."""
|
|
167
|
+
maximum = edits.get("hit_points_max", character.hit_points_max)
|
|
168
|
+
if edits.get("exhaustion", character.exhaustion) >= 4:
|
|
169
|
+
maximum = max(1, maximum // 2)
|
|
170
|
+
current = edits.get("hit_points_current")
|
|
171
|
+
if current is None:
|
|
172
|
+
edits["hit_points_current"] = min(character.hit_points_current, maximum)
|
|
173
|
+
elif current > maximum:
|
|
174
|
+
raise InvalidChangeError(
|
|
175
|
+
f"{character.name}'s current HP ({current}) cannot exceed max HP ({maximum})."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def change_hp(character: Character, delta: int) -> str:
|
|
180
|
+
"""Heal or damage one character, following the 0 HP rules (PHB ch. 9)."""
|
|
181
|
+
was_down = character.hit_points_current == 0
|
|
182
|
+
if delta > 0 and character.death_saves_failure >= 3:
|
|
183
|
+
raise InvalidChangeError(
|
|
184
|
+
f"{character.name} is dead: healing does not bring them back. If a spell does "
|
|
185
|
+
"(revivify, raise dead), set their hit points with update_character."
|
|
186
|
+
)
|
|
187
|
+
maximum = character.hp_maximum
|
|
188
|
+
hp = rules.apply_hp_change(
|
|
189
|
+
character.hit_points_current, maximum, character.temporary_hit_points, delta
|
|
190
|
+
)
|
|
191
|
+
overflow = -delta - (character.temporary_hit_points - hp.temporary)
|
|
192
|
+
overflow -= character.hit_points_current
|
|
193
|
+
character.hit_points_current = hp.current
|
|
194
|
+
character.temporary_hit_points = hp.temporary
|
|
195
|
+
character.updated_at = now()
|
|
196
|
+
status = f"{character.name}: HP {hp.current}/{maximum}"
|
|
197
|
+
if delta > 0:
|
|
198
|
+
return _healed(character, status) if was_down and hp.current > 0 else status
|
|
199
|
+
if hp.current > 0:
|
|
200
|
+
return status
|
|
201
|
+
if overflow >= maximum:
|
|
202
|
+
character.death_saves_failure, character.stable = 3, False
|
|
203
|
+
return f"{status}, killed outright by massive damage"
|
|
204
|
+
if was_down:
|
|
205
|
+
character.death_saves_failure = min(3, character.death_saves_failure + 1)
|
|
206
|
+
character.stable = False
|
|
207
|
+
return (
|
|
208
|
+
f"{status}, hit while down: {character.death_saves_failure}/3 death save failures "
|
|
209
|
+
"(a critical hit counts two: record_death_save failure once more)"
|
|
210
|
+
)
|
|
211
|
+
_add_conditions(character, "unconscious", "prone")
|
|
212
|
+
return f"{status}, unconscious and prone: death saving throws from now on"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _healed(character: Character, status: str) -> str:
|
|
216
|
+
character.death_saves_success = 0
|
|
217
|
+
character.death_saves_failure = 0
|
|
218
|
+
character.stable = False
|
|
219
|
+
character.conditions = [c for c in character.conditions if c != "unconscious"]
|
|
220
|
+
return f"{status}, conscious again (still prone; death saves reset)"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _add_conditions(character: Character, *names: str) -> None:
|
|
224
|
+
character.conditions = [
|
|
225
|
+
*character.conditions,
|
|
226
|
+
*(n for n in names if n not in character.conditions),
|
|
227
|
+
]
|
dndkit/game/combat.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""The initiative order: whose turn it is, by position rather than by name.
|
|
2
|
+
|
|
3
|
+
Turns are tracked by index, so three goblins each get their turn; same-named
|
|
4
|
+
combatants are numbered ("Goblin 1", "Goblin 2") so they can be told apart
|
|
5
|
+
aloud. Foes who fall or flee move to ``defeated``, which the end of the combat
|
|
6
|
+
turns into an XP total; the party and its allies earn none.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from dndkit.errors import InvalidChangeError, NotFoundError
|
|
14
|
+
from dndkit.game import registry
|
|
15
|
+
from dndkit.game.combatants import number_duplicates, unique_name
|
|
16
|
+
from dndkit.models import Combatant, CombatRecord, GameState
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class CombatSummary:
|
|
21
|
+
"""What a finished combat leaves: its length and who was overcome."""
|
|
22
|
+
|
|
23
|
+
rounds: int
|
|
24
|
+
defeated: list[Combatant]
|
|
25
|
+
xp: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def start(
|
|
29
|
+
state: GameState, combatants: Sequence[Combatant], encounter_id: str | None = None
|
|
30
|
+
) -> Combatant:
|
|
31
|
+
"""Sort by initiative (ties keep the given order) and open round 1."""
|
|
32
|
+
if state.in_combat:
|
|
33
|
+
raise InvalidChangeError("A combat is already running. Use add_combatant or end_combat.")
|
|
34
|
+
if state.combat_round:
|
|
35
|
+
raise InvalidChangeError("The last fight was not closed: call end_combat first.")
|
|
36
|
+
if not combatants:
|
|
37
|
+
raise InvalidChangeError("A combat needs at least one participant.")
|
|
38
|
+
linked = Counter(c.character_id for c in combatants if c.character_id)
|
|
39
|
+
twice = sorted({c.name for c in combatants if c.character_id and linked[c.character_id] > 1})
|
|
40
|
+
if twice:
|
|
41
|
+
raise InvalidChangeError(f"Given twice: {', '.join(twice)}. Name each one once.")
|
|
42
|
+
ordered = sorted(combatants, key=lambda c: c.initiative, reverse=True)
|
|
43
|
+
state.initiative_order = number_duplicates(ordered)
|
|
44
|
+
state.in_combat = True
|
|
45
|
+
state.turn_index = 0
|
|
46
|
+
state.combat_round = 1
|
|
47
|
+
state.defeated = []
|
|
48
|
+
state.encounter_id = encounter_id
|
|
49
|
+
return state.initiative_order[0]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def add(state: GameState, combatant: Combatant) -> Combatant:
|
|
53
|
+
"""Insert a newcomer by initiative without changing whose turn it is."""
|
|
54
|
+
_require_combat(state)
|
|
55
|
+
if combatant.character_id and any(
|
|
56
|
+
c.character_id == combatant.character_id for c in state.initiative_order
|
|
57
|
+
):
|
|
58
|
+
raise InvalidChangeError(f"{combatant.name} is already in the fight.")
|
|
59
|
+
if combatant.character_id is None:
|
|
60
|
+
name = unique_name(state.initiative_order, combatant.name)
|
|
61
|
+
combatant = combatant.model_copy(update={"name": name})
|
|
62
|
+
order = state.initiative_order
|
|
63
|
+
position = next(
|
|
64
|
+
(i for i, c in enumerate(order) if combatant.initiative > c.initiative), len(order)
|
|
65
|
+
)
|
|
66
|
+
state.initiative_order = [*order[:position], combatant, *order[position:]]
|
|
67
|
+
if position <= state.turn_index:
|
|
68
|
+
state.turn_index += 1
|
|
69
|
+
return combatant
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def next_turn(state: GameState) -> Combatant:
|
|
73
|
+
"""Advance one turn; passing the last combatant starts a new round."""
|
|
74
|
+
_require_combat(state)
|
|
75
|
+
state.turn_index += 1
|
|
76
|
+
if state.turn_index >= len(state.initiative_order):
|
|
77
|
+
state.turn_index = 0
|
|
78
|
+
state.combat_round += 1
|
|
79
|
+
if state.battlefield is not None: # the last area of effect is spent
|
|
80
|
+
state.battlefield.area, state.battlefield.area_label = [], None
|
|
81
|
+
return _begin_turn(state.initiative_order[state.turn_index])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find(state: GameState, name: str) -> Combatant:
|
|
85
|
+
"""The combatant *name* names (case and accents ignored), or the only one whose
|
|
86
|
+
name holds it ("gardien" for "Gardien de Magma"), as spoken names go."""
|
|
87
|
+
_require_combat(state)
|
|
88
|
+
found = find_optional(state, name)
|
|
89
|
+
if found is None:
|
|
90
|
+
names = [c.name for c in state.initiative_order]
|
|
91
|
+
near = registry.did_you_mean(names, name)
|
|
92
|
+
raise NotFoundError(
|
|
93
|
+
f"No combatant named '{name}'.{near} In the fight: {', '.join(names)}."
|
|
94
|
+
)
|
|
95
|
+
return found
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def find_optional(state: GameState, name: str) -> Combatant | None:
|
|
99
|
+
"""Like ``find``, or None."""
|
|
100
|
+
wanted = registry.normalize(name)
|
|
101
|
+
for combatant in state.initiative_order:
|
|
102
|
+
if registry.normalize(combatant.name) == wanted:
|
|
103
|
+
return combatant
|
|
104
|
+
partial = registry.partial_matches(state.initiative_order, name)
|
|
105
|
+
return partial[0] if len(partial) == 1 else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def remove(
|
|
109
|
+
state: GameState, name: str, overcome: bool = True
|
|
110
|
+
) -> tuple[Combatant, Combatant | None]:
|
|
111
|
+
"""Take a combatant out of the fight and off the map; returns it and whoever acts now.
|
|
112
|
+
|
|
113
|
+
An *overcome* foe (fallen or fled) joins the defeated and counts for XP; one
|
|
114
|
+
taken back (a mistake, a change of plan) leaves no trace. Removing the last
|
|
115
|
+
combatant ends the combat and returns None as the second value.
|
|
116
|
+
"""
|
|
117
|
+
removed = find(state, name)
|
|
118
|
+
index = next(i for i, c in enumerate(state.initiative_order) if c is removed)
|
|
119
|
+
state.initiative_order = [c for c in state.initiative_order if c is not removed]
|
|
120
|
+
if overcome and removed.character_id is None and not removed.ally:
|
|
121
|
+
state.defeated = [*state.defeated, removed]
|
|
122
|
+
if not state.initiative_order:
|
|
123
|
+
state.in_combat = False
|
|
124
|
+
return removed, None
|
|
125
|
+
if index < state.turn_index:
|
|
126
|
+
state.turn_index -= 1
|
|
127
|
+
elif index == state.turn_index: # the one acting left: the next one's turn starts
|
|
128
|
+
if state.turn_index >= len(state.initiative_order):
|
|
129
|
+
state.turn_index = 0
|
|
130
|
+
state.combat_round += 1
|
|
131
|
+
_begin_turn(state.initiative_order[state.turn_index])
|
|
132
|
+
return removed, state.initiative_order[state.turn_index]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def end(state: GameState) -> CombatSummary:
|
|
136
|
+
"""Close the combat, or what is left of one whose last combatant was removed;
|
|
137
|
+
the summary lists who was overcome and their total XP."""
|
|
138
|
+
if not state.in_combat and not state.combat_round:
|
|
139
|
+
raise InvalidChangeError("No combat is running.")
|
|
140
|
+
summary = CombatSummary(
|
|
141
|
+
rounds=state.combat_round,
|
|
142
|
+
defeated=list(state.defeated),
|
|
143
|
+
xp=sum(c.xp for c in state.defeated),
|
|
144
|
+
)
|
|
145
|
+
state.last_combat = CombatRecord(
|
|
146
|
+
rounds=summary.rounds,
|
|
147
|
+
fighters=[c.name for c in [*state.initiative_order, *state.defeated]],
|
|
148
|
+
defeated=[c.name for c in summary.defeated],
|
|
149
|
+
xp=summary.xp,
|
|
150
|
+
)
|
|
151
|
+
state.in_combat = False
|
|
152
|
+
state.initiative_order = []
|
|
153
|
+
state.turn_index = 0
|
|
154
|
+
state.combat_round = 0
|
|
155
|
+
state.defeated = []
|
|
156
|
+
state.encounter_id = None
|
|
157
|
+
state.battlefield = None # the next fight lays out its own
|
|
158
|
+
return summary
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _begin_turn(combatant: Combatant) -> Combatant:
|
|
162
|
+
"""A turn starts with the full movement, no Dash or Disengage yet, the reaction
|
|
163
|
+
and the legendary actions back (MM, "Legendary Creatures")."""
|
|
164
|
+
combatant.movement_used = 0
|
|
165
|
+
combatant.dashed = False
|
|
166
|
+
combatant.disengaged = False
|
|
167
|
+
combatant.reaction_used = False
|
|
168
|
+
combatant.legendary_actions_left = combatant.legendary_actions
|
|
169
|
+
return combatant
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def use_legendary(state: GameState, name: str, resistance: bool, cost: int) -> Combatant:
|
|
173
|
+
"""Spend a legendary action (*cost* of them) or a legendary resistance."""
|
|
174
|
+
creature = find(state, name)
|
|
175
|
+
if resistance:
|
|
176
|
+
if creature.legendary_resistances_left < 1:
|
|
177
|
+
raise InvalidChangeError(f"{creature.name} has no legendary resistance left.")
|
|
178
|
+
creature.legendary_resistances_left -= 1
|
|
179
|
+
return creature
|
|
180
|
+
if not creature.legendary_actions:
|
|
181
|
+
raise InvalidChangeError(f"{creature.name} has no legendary actions.")
|
|
182
|
+
if creature is state.current_combatant:
|
|
183
|
+
raise InvalidChangeError("Legendary actions come at the end of another creature's turn.")
|
|
184
|
+
if creature.legendary_actions_left < cost:
|
|
185
|
+
raise InvalidChangeError(
|
|
186
|
+
f"{creature.name} has {creature.legendary_actions_left} legendary action(s) left "
|
|
187
|
+
"this round; they come back at the start of its turn."
|
|
188
|
+
)
|
|
189
|
+
creature.legendary_actions_left -= cost
|
|
190
|
+
return creature
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def use_reaction(state: GameState, name: str, able: bool) -> Combatant:
|
|
194
|
+
"""Spend a combatant's reaction for the round; *able*: it can react at all."""
|
|
195
|
+
creature = find(state, name)
|
|
196
|
+
if creature.reaction_used:
|
|
197
|
+
raise InvalidChangeError(
|
|
198
|
+
f"{creature.name} has used its reaction this round; it comes back at the "
|
|
199
|
+
"start of its turn."
|
|
200
|
+
)
|
|
201
|
+
if not able:
|
|
202
|
+
raise InvalidChangeError(f"{creature.name} cannot react: down or incapacitated.")
|
|
203
|
+
creature.reaction_used = True
|
|
204
|
+
return creature
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _require_combat(state: GameState) -> None:
|
|
208
|
+
if not state.in_combat or not state.initiative_order:
|
|
209
|
+
raise InvalidChangeError("No combat is running. Start one with start_combat.")
|