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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Turning participants into combatants: characters linked, monsters filled in."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import secrets
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dndkit.game import bestiary, characters, dice, registry, seating
|
|
10
|
+
from dndkit.game.dice import RollResult
|
|
11
|
+
from dndkit.models import Campaign, Character, Combatant, Encounter, Monster, Participant
|
|
12
|
+
|
|
13
|
+
_SYSTEM_RNG = secrets.SystemRandom()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build(campaign: Campaign, participant: Participant) -> Combatant:
|
|
17
|
+
"""A combatant for *participant*.
|
|
18
|
+
|
|
19
|
+
A party character is linked to its sheet (hit points live there). A name
|
|
20
|
+
found in the bestiary takes that stat block, unless the call gives its own
|
|
21
|
+
hit points or armor class. Anyone else keeps what the call gives.
|
|
22
|
+
"""
|
|
23
|
+
name = participant.name.strip()
|
|
24
|
+
character = character_for(campaign, name)
|
|
25
|
+
if character is not None:
|
|
26
|
+
return Combatant(
|
|
27
|
+
name=character.name, initiative=participant.initiative, character_id=character.id
|
|
28
|
+
)
|
|
29
|
+
monster = bestiary.monster_for(campaign, name)
|
|
30
|
+
hit_points = participant.hit_points or (monster.hit_points if monster else None)
|
|
31
|
+
armor_class = participant.armor_class
|
|
32
|
+
if armor_class is None and monster is not None:
|
|
33
|
+
armor_class = monster.armor_class
|
|
34
|
+
return Combatant(
|
|
35
|
+
name=name,
|
|
36
|
+
initiative=participant.initiative,
|
|
37
|
+
monster=monster.name if monster else None,
|
|
38
|
+
hit_points=hit_points,
|
|
39
|
+
hit_points_max=hit_points,
|
|
40
|
+
armor_class=armor_class,
|
|
41
|
+
xp=monster.xp if monster else 0,
|
|
42
|
+
size=monster.size if monster else "medium",
|
|
43
|
+
speed=bestiary.walking_speed(monster) if monster else None,
|
|
44
|
+
**(legendary(monster) if monster else {}),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def legendary(monster: Monster) -> dict[str, Any]:
|
|
49
|
+
"""A combatant's boss fields from its stat block, its legendary uses full."""
|
|
50
|
+
return {
|
|
51
|
+
"boss": monster.boss,
|
|
52
|
+
"legendary_actions": monster.legendary_actions,
|
|
53
|
+
"legendary_actions_left": monster.legendary_actions,
|
|
54
|
+
"legendary_resistances_left": monster.legendary_resistances,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def character_for(campaign: Campaign, name: str) -> Character | None:
|
|
59
|
+
"""The party character a combatant name stands for: its name, whole words of it
|
|
60
|
+
("Aria" for "Aria Moonwhisper"), or a name that holds it ("Fizbin the gnome").
|
|
61
|
+
A part of a word is not enough: a "Wolf" is not "Wolfgang"."""
|
|
62
|
+
found = registry.find_exact(campaign.characters, name)
|
|
63
|
+
if found is not None:
|
|
64
|
+
return found
|
|
65
|
+
words = f" {registry.normalize(name)} "
|
|
66
|
+
holding = [
|
|
67
|
+
c
|
|
68
|
+
for c in campaign.characters.values()
|
|
69
|
+
if f" {registry.normalize(c.name)} " in words or words in f" {registry.normalize(c.name)} "
|
|
70
|
+
]
|
|
71
|
+
return holding[0] if len(holding) == 1 else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def refresh(campaign: Campaign) -> list[str]:
|
|
75
|
+
"""Bring the running fight up to date with the sheets and the bestiary.
|
|
76
|
+
|
|
77
|
+
A combatant named after a party character but not linked to its sheet is
|
|
78
|
+
linked (its hit points, conditions and speed then come from the sheet); a
|
|
79
|
+
monster fighting without hit points takes its stat block's, once saved.
|
|
80
|
+
Returns a line per combatant changed.
|
|
81
|
+
"""
|
|
82
|
+
lines: list[str] = []
|
|
83
|
+
order = campaign.game_state.initiative_order
|
|
84
|
+
linked = {c.character_id for c in order if c.character_id}
|
|
85
|
+
for combatant in order:
|
|
86
|
+
if combatant.character_id is not None:
|
|
87
|
+
continue
|
|
88
|
+
character = character_for(campaign, combatant.name)
|
|
89
|
+
if character is not None and character.id not in linked:
|
|
90
|
+
combatant.character_id, combatant.hit_points, combatant.hit_points_max = (
|
|
91
|
+
character.id,
|
|
92
|
+
None,
|
|
93
|
+
None,
|
|
94
|
+
)
|
|
95
|
+
linked.add(character.id)
|
|
96
|
+
lines.append(f"{combatant.name} now plays from {character.name}'s sheet.")
|
|
97
|
+
continue
|
|
98
|
+
monster = bestiary.monster_for(campaign, combatant.name)
|
|
99
|
+
if monster is not None and combatant.hit_points is None:
|
|
100
|
+
_take_stats(combatant, monster)
|
|
101
|
+
lines.append(
|
|
102
|
+
f"{combatant.name} takes the {monster.name} stat block: "
|
|
103
|
+
f"HP {monster.hit_points}, AC {combatant.armor_class}."
|
|
104
|
+
)
|
|
105
|
+
return lines
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _take_stats(combatant: Combatant, monster: Monster) -> None:
|
|
109
|
+
combatant.monster = monster.name
|
|
110
|
+
combatant.hit_points = combatant.hit_points_max = monster.hit_points
|
|
111
|
+
if combatant.armor_class is None:
|
|
112
|
+
combatant.armor_class = monster.armor_class
|
|
113
|
+
if combatant.speed is None:
|
|
114
|
+
combatant.speed = bestiary.walking_speed(monster)
|
|
115
|
+
combatant.xp = monster.xp
|
|
116
|
+
combatant.size = monster.size
|
|
117
|
+
for field, value in legendary(monster).items():
|
|
118
|
+
setattr(combatant, field, value)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def from_encounter(
|
|
122
|
+
campaign: Campaign, encounter: Encounter, rng: random.Random | None = None
|
|
123
|
+
) -> list[Combatant]:
|
|
124
|
+
"""The encounter's monsters, each with initiative rolled (d20 + bonus)."""
|
|
125
|
+
rng = rng or _SYSTEM_RNG
|
|
126
|
+
combatants: list[Combatant] = []
|
|
127
|
+
for group in encounter.monsters:
|
|
128
|
+
monster = bestiary.find_monster(campaign, group.monster)
|
|
129
|
+
for number in range(group.count):
|
|
130
|
+
combatants.append(
|
|
131
|
+
Combatant(
|
|
132
|
+
name=monster.name,
|
|
133
|
+
initiative=rng.randint(1, 20) + monster.initiative_bonus,
|
|
134
|
+
monster=monster.name,
|
|
135
|
+
hit_points=monster.hit_points,
|
|
136
|
+
hit_points_max=monster.hit_points,
|
|
137
|
+
armor_class=monster.armor_class,
|
|
138
|
+
xp=monster.xp,
|
|
139
|
+
size=monster.size,
|
|
140
|
+
speed=bestiary.walking_speed(monster),
|
|
141
|
+
position=group.at[number].upper() if number < len(group.at) else None,
|
|
142
|
+
hidden=group.hidden,
|
|
143
|
+
**legendary(monster),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return combatants
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def party_at_the_table(campaign: Campaign) -> list[tuple[Combatant, RollResult]]:
|
|
150
|
+
"""The characters at the table, each with initiative rolled (d20 + Dexterity):
|
|
151
|
+
for a fight started with its foes only."""
|
|
152
|
+
joined: list[tuple[Combatant, RollResult]] = []
|
|
153
|
+
for character in seating.present_characters(campaign):
|
|
154
|
+
bonus = characters.initiative(character)
|
|
155
|
+
result = dice.roll(f"d20{bonus:+d}" if bonus else "d20")
|
|
156
|
+
fighter = Combatant(
|
|
157
|
+
name=character.name, initiative=result.total, character_id=character.id
|
|
158
|
+
)
|
|
159
|
+
joined.append((fighter, result))
|
|
160
|
+
return joined
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def number_duplicates(order: Sequence[Combatant]) -> list[Combatant]:
|
|
164
|
+
"""Same-named combatants become "Goblin 1", "Goblin 2"... so they can be told apart."""
|
|
165
|
+
totals = Counter(registry.normalize(c.name) for c in order)
|
|
166
|
+
taken = set(totals)
|
|
167
|
+
seen: Counter[str] = Counter()
|
|
168
|
+
numbered: list[Combatant] = []
|
|
169
|
+
for combatant in order:
|
|
170
|
+
key = registry.normalize(combatant.name)
|
|
171
|
+
if totals[key] > 1 and combatant.character_id is None:
|
|
172
|
+
seen[key] += 1
|
|
173
|
+
while f"{key} {seen[key]}" in taken: # a "Goblin 1" given by name
|
|
174
|
+
seen[key] += 1
|
|
175
|
+
combatant = combatant.model_copy(update={"name": f"{combatant.name} {seen[key]}"})
|
|
176
|
+
numbered.append(combatant)
|
|
177
|
+
return numbered
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def unique_name(taken: Sequence[Combatant], name: str) -> str:
|
|
181
|
+
"""*name*, or the next free "name N" when the fight already has one."""
|
|
182
|
+
names = {registry.normalize(c.name) for c in taken}
|
|
183
|
+
if registry.normalize(name) not in names and registry.normalize(f"{name} 1") not in names:
|
|
184
|
+
return name
|
|
185
|
+
number = 2
|
|
186
|
+
while registry.normalize(f"{name} {number}") in names:
|
|
187
|
+
number += 1
|
|
188
|
+
return f"{name} {number}"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Concentration (PHB ch. 10): one spell held at a time, a Constitution save
|
|
2
|
+
when hurt (DC 10 or half the damage), lost when incapacitated or at 0 hit points."""
|
|
3
|
+
|
|
4
|
+
from dndkit.game import proficiencies
|
|
5
|
+
from dndkit.models import Character, Combatant
|
|
6
|
+
from dndkit.text import normalize
|
|
7
|
+
|
|
8
|
+
INCAPACITATING = frozenset({"incapacitated", "paralyzed", "petrified", "stunned", "unconscious"})
|
|
9
|
+
|
|
10
|
+
Holder = Character | Combatant
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def concentrate(holder: Holder, name: str, spell: str | None) -> str:
|
|
14
|
+
"""Hold *spell*, letting go of any other; without one, stop concentrating."""
|
|
15
|
+
before = holder.concentration
|
|
16
|
+
spell = spell.strip() if spell else None
|
|
17
|
+
holder.concentration = spell or None
|
|
18
|
+
if spell:
|
|
19
|
+
same = before is not None and normalize(before) == normalize(spell)
|
|
20
|
+
ended = f" ({before} ends)" if before and not same else ""
|
|
21
|
+
return f"{name} concentrates on {spell}{ended}."
|
|
22
|
+
return f"{name} stops concentrating on {before}." if before else f"{name} held no spell."
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def after_damage(holder: Holder, name: str, damage: int, down: bool) -> str | None:
|
|
26
|
+
"""The save a hurt creature makes to keep its spell, or the spell lost at 0 HP."""
|
|
27
|
+
if not holder.concentration:
|
|
28
|
+
return None
|
|
29
|
+
if down or INCAPACITATING & set(holder.conditions):
|
|
30
|
+
return lose(holder, name)
|
|
31
|
+
bonus = ""
|
|
32
|
+
if isinstance(holder, Character):
|
|
33
|
+
bonus = f", CON save {proficiencies.save_bonus(holder, 'constitution'):+d}"
|
|
34
|
+
return (
|
|
35
|
+
f"{name} is concentrating on {holder.concentration}: Constitution save DC "
|
|
36
|
+
f"{max(10, damage // 2)}{bonus}. If it fails, set_concentration without a spell."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def lose(holder: Holder, name: str) -> str | None:
|
|
41
|
+
"""Let go of the spell held, if any: incapacitated, down, or dead."""
|
|
42
|
+
spell, holder.concentration = holder.concentration, None
|
|
43
|
+
return f"{name} loses concentration on {spell}." if spell else None
|
dndkit/game/dice.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Dice notation: parse "2d6+1d4+3" or "d20 - 1" and roll it."""
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import random
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections import deque
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from dndkit.errors import DiceError
|
|
14
|
+
|
|
15
|
+
MAX_DICE = 100
|
|
16
|
+
MAX_SIDES = 1000
|
|
17
|
+
MAX_CONSTANT = 1000
|
|
18
|
+
_EXAMPLES = "Use forms like d20, 1d20+5, 2d6+1d4+3 or 4d6-1."
|
|
19
|
+
_TERM = re.compile(r"\s*([+-])?\s*(?:(\d*)\s*d\s*(\d+)|(\d+))\s*", re.IGNORECASE)
|
|
20
|
+
_SYSTEM_RNG = secrets.SystemRandom()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class DiceTerm:
|
|
25
|
+
"""``count`` dice of ``sides`` faces, or a constant when ``sides`` is 0."""
|
|
26
|
+
|
|
27
|
+
sign: int
|
|
28
|
+
count: int
|
|
29
|
+
sides: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class RolledTerm:
|
|
34
|
+
"""One term as rolled: every die's face, or the constant (``sides`` 0).
|
|
35
|
+
|
|
36
|
+
With advantage or disadvantage the term holds both d20 and ``kept`` says
|
|
37
|
+
which value counts.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
sign: int
|
|
41
|
+
sides: int
|
|
42
|
+
faces: tuple[int, ...]
|
|
43
|
+
kept: int | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def value(self) -> int:
|
|
47
|
+
"""What the term adds to the total, before its sign."""
|
|
48
|
+
return self.kept if self.kept is not None else sum(self.faces)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class RollResult:
|
|
53
|
+
"""What was rolled: the line to read out, and every die for an animation."""
|
|
54
|
+
|
|
55
|
+
notation: str
|
|
56
|
+
total: int
|
|
57
|
+
detail: str
|
|
58
|
+
mode: str # normal, advantage, disadvantage
|
|
59
|
+
terms: tuple[RolledTerm, ...]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse(notation: str) -> list[DiceTerm]:
|
|
63
|
+
"""The terms of *notation*; raises ``DiceError`` on anything else."""
|
|
64
|
+
text = notation.strip()
|
|
65
|
+
terms: list[DiceTerm] = []
|
|
66
|
+
position = 0
|
|
67
|
+
while position < len(text):
|
|
68
|
+
match = _TERM.match(text, position)
|
|
69
|
+
if match is None or match.end() == position or (terms and match.group(1) is None):
|
|
70
|
+
raise DiceError(f"Invalid dice notation '{notation}'. {_EXAMPLES}")
|
|
71
|
+
terms.append(_term(match, notation))
|
|
72
|
+
position = match.end()
|
|
73
|
+
if not any(term.sides for term in terms):
|
|
74
|
+
raise DiceError(f"'{notation}' has no dice. {_EXAMPLES}")
|
|
75
|
+
if sum(term.count for term in terms if term.sides) > MAX_DICE:
|
|
76
|
+
raise DiceError(f"'{notation}' rolls more than {MAX_DICE} dice.")
|
|
77
|
+
return terms
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _term(match: re.Match[str], notation: str) -> DiceTerm:
|
|
81
|
+
sign = -1 if match.group(1) == "-" else 1
|
|
82
|
+
if match.group(4) is not None:
|
|
83
|
+
constant = int(match.group(4))
|
|
84
|
+
if constant > MAX_CONSTANT:
|
|
85
|
+
raise DiceError(f"'{notation}': modifiers go up to {MAX_CONSTANT}.")
|
|
86
|
+
return DiceTerm(sign, constant, 0)
|
|
87
|
+
count = int(match.group(2) or 1)
|
|
88
|
+
sides = int(match.group(3))
|
|
89
|
+
if not 1 <= count <= MAX_DICE:
|
|
90
|
+
raise DiceError(f"'{notation}': roll between 1 and {MAX_DICE} dice.")
|
|
91
|
+
if not 2 <= sides <= MAX_SIDES:
|
|
92
|
+
raise DiceError(f"'{notation}': dice have between 2 and {MAX_SIDES} sides.")
|
|
93
|
+
return DiceTerm(sign, count, sides)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def roll(
|
|
97
|
+
notation: str,
|
|
98
|
+
*,
|
|
99
|
+
advantage: bool = False,
|
|
100
|
+
disadvantage: bool = False,
|
|
101
|
+
rng: random.Random | None = None,
|
|
102
|
+
) -> RollResult:
|
|
103
|
+
"""Roll *notation*; advantage and disadvantage apply to a single d20."""
|
|
104
|
+
terms = parse(notation)
|
|
105
|
+
rng = rng or _SYSTEM_RNG
|
|
106
|
+
mode = _mode(terms, notation, advantage, disadvantage)
|
|
107
|
+
rolled = tuple(_roll_term(term, rng, mode) for term in terms)
|
|
108
|
+
total = sum(term.sign * term.value for term in rolled)
|
|
109
|
+
parts: list[str] = []
|
|
110
|
+
for term in rolled:
|
|
111
|
+
shown = _describe(term, mode)
|
|
112
|
+
sign = "-" if term.sign < 0 else "+"
|
|
113
|
+
parts.append(f"{sign} {shown}" if parts or term.sign < 0 else shown)
|
|
114
|
+
detail = " ".join(parts)
|
|
115
|
+
if advantage and disadvantage:
|
|
116
|
+
detail += " (advantage and disadvantage cancel out)"
|
|
117
|
+
return RollResult(
|
|
118
|
+
notation=notation.strip(),
|
|
119
|
+
total=total,
|
|
120
|
+
detail=f"{detail} = {total}",
|
|
121
|
+
mode=mode,
|
|
122
|
+
terms=rolled,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _mode(terms: list[DiceTerm], notation: str, advantage: bool, disadvantage: bool) -> str:
|
|
127
|
+
if advantage == disadvantage:
|
|
128
|
+
return "normal"
|
|
129
|
+
dice = [term for term in terms if term.sides]
|
|
130
|
+
if len(dice) != 1 or dice[0].count != 1 or dice[0].sides != 20:
|
|
131
|
+
raise DiceError(f"Advantage and disadvantage apply to one d20, not '{notation}'.")
|
|
132
|
+
return "advantage" if advantage else "disadvantage"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _roll_term(term: DiceTerm, rng: random.Random, mode: str) -> RolledTerm:
|
|
136
|
+
if not term.sides:
|
|
137
|
+
return RolledTerm(term.sign, 0, (term.count,))
|
|
138
|
+
if mode != "normal":
|
|
139
|
+
faces = (rng.randint(1, 20), rng.randint(1, 20))
|
|
140
|
+
kept = max(faces) if mode == "advantage" else min(faces)
|
|
141
|
+
return RolledTerm(term.sign, 20, faces, kept)
|
|
142
|
+
return RolledTerm(
|
|
143
|
+
term.sign, term.sides, tuple(rng.randint(1, term.sides) for _ in range(term.count))
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _describe(term: RolledTerm, mode: str) -> str:
|
|
148
|
+
if not term.sides:
|
|
149
|
+
return str(term.faces[0])
|
|
150
|
+
if term.kept is not None:
|
|
151
|
+
return f"d20 {mode} [{term.faces[0]}, {term.faces[1]}] keeps {term.kept}"
|
|
152
|
+
return f"{len(term.faces)}d{term.sides} [{', '.join(map(str, term.faces))}]"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class LoggedRoll:
|
|
157
|
+
"""A roll in the log: its sequence number, when (unix time), and whether only
|
|
158
|
+
the game master may see it."""
|
|
159
|
+
|
|
160
|
+
number: int
|
|
161
|
+
at: float
|
|
162
|
+
result: RollResult
|
|
163
|
+
secret: bool = False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class DiceLog:
|
|
167
|
+
"""The last rolls of this session, for the dice app to show; never saved."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, size: int = 20, on_record: Callable[[], None] | None = None) -> None:
|
|
170
|
+
self._rolls: deque[LoggedRoll] = deque(maxlen=size)
|
|
171
|
+
self._counter = itertools.count(1)
|
|
172
|
+
self._lock = threading.Lock()
|
|
173
|
+
self._on_record = on_record or (lambda: None)
|
|
174
|
+
|
|
175
|
+
def record(self, result: RollResult, secret: bool = False) -> int:
|
|
176
|
+
"""Keep *result*; returns its sequence number."""
|
|
177
|
+
with self._lock:
|
|
178
|
+
number = next(self._counter)
|
|
179
|
+
self._rolls.append(LoggedRoll(number, time.time(), result, secret))
|
|
180
|
+
self._on_record()
|
|
181
|
+
return number
|
|
182
|
+
|
|
183
|
+
def recent(self) -> list[LoggedRoll]:
|
|
184
|
+
"""The rolls kept, oldest first."""
|
|
185
|
+
with self._lock:
|
|
186
|
+
return list(self._rolls)
|
dndkit/game/effects.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Hit points and conditions for anyone at the table: characters or combatants.
|
|
2
|
+
|
|
3
|
+
A whole name is looked up in the running fight first, then among the party;
|
|
4
|
+
only then a part of a name, if it points to one target alone. A combatant
|
|
5
|
+
linked to a character is changed on the character's sheet; any other
|
|
6
|
+
combatant keeps its own hit points and conditions, and leaves the initiative
|
|
7
|
+
order when it drops to 0 HP. Each change first brings the fight up
|
|
8
|
+
to date (``combatants.refresh``): a sheet created or a stat block saved after
|
|
9
|
+
the fight began takes effect at once.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from dndkit.errors import InvalidChangeError, NotFoundError
|
|
16
|
+
from dndkit.game import characters, combat, combatants, concentration, registry, rules
|
|
17
|
+
from dndkit.game.concentration import INCAPACITATING
|
|
18
|
+
from dndkit.models import CONDITIONS, Campaign, Character, Combatant, now
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Target:
|
|
23
|
+
"""One resolved name: a party character, or a combatant with its own stats."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
character: Character | None = None
|
|
27
|
+
combatant: Combatant | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve(campaign: Campaign, refs: Sequence[str]) -> list[Target]:
|
|
31
|
+
"""Every target in *refs*, once each, or one error naming all the unknown ones."""
|
|
32
|
+
state = campaign.game_state
|
|
33
|
+
fighters = state.initiative_order if state.in_combat else []
|
|
34
|
+
targets: list[Target] = []
|
|
35
|
+
missing: list[str] = []
|
|
36
|
+
for ref in refs:
|
|
37
|
+
target = _target(campaign, fighters, ref)
|
|
38
|
+
if target is None:
|
|
39
|
+
missing.append(ref)
|
|
40
|
+
elif all(t.name != target.name for t in targets):
|
|
41
|
+
targets.append(target)
|
|
42
|
+
if missing:
|
|
43
|
+
known = [c.name for c in fighters] + [c.name for c in campaign.characters.values()]
|
|
44
|
+
unknown = ", ".join(registry.quoted(known, ref) for ref in missing)
|
|
45
|
+
raise NotFoundError(f"Unknown: {unknown}. Known: {', '.join(sorted(set(known)))}.")
|
|
46
|
+
return targets
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _target(campaign: Campaign, fighters: Sequence[Combatant], ref: str) -> Target | None:
|
|
50
|
+
"""A whole name, a fighter's then a character's; else the only one a part names.
|
|
51
|
+
"Thorn" is the character Thorn, not the "Thornback Boar"."""
|
|
52
|
+
wanted = registry.normalize(ref)
|
|
53
|
+
fighter = next((c for c in fighters if registry.normalize(c.name) == wanted), None)
|
|
54
|
+
if fighter is not None:
|
|
55
|
+
return _fighting(campaign, fighter)
|
|
56
|
+
character = registry.find_exact(campaign.characters, ref)
|
|
57
|
+
if character is not None:
|
|
58
|
+
return Target(character.name, character=character)
|
|
59
|
+
in_fight = registry.partial_matches(fighters, ref)
|
|
60
|
+
linked = {c.character_id for c in in_fight}
|
|
61
|
+
sheets = registry.partial_matches(campaign.characters.values(), ref)
|
|
62
|
+
sheets = [c for c in sheets if c.id not in linked]
|
|
63
|
+
if len(in_fight) + len(sheets) != 1:
|
|
64
|
+
return None
|
|
65
|
+
return _fighting(campaign, in_fight[0]) if in_fight else Target(sheets[0].name, sheets[0])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _fighting(campaign: Campaign, fighter: Combatant) -> Target | None:
|
|
69
|
+
"""A fighter as a target: its sheet when it is a party character."""
|
|
70
|
+
if fighter.character_id is None:
|
|
71
|
+
return Target(fighter.name, combatant=fighter)
|
|
72
|
+
character = campaign.characters.get(fighter.character_id)
|
|
73
|
+
return Target(character.name, character=character) if character else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def change_hit_points(campaign: Campaign, refs: Sequence[str], amount: int) -> list[str]:
|
|
77
|
+
"""Damage (negative) or heal (positive) each target; one line per target."""
|
|
78
|
+
lines = combatants.refresh(campaign)
|
|
79
|
+
for target in resolve(campaign, refs):
|
|
80
|
+
holder: Character | Combatant
|
|
81
|
+
if target.character is not None:
|
|
82
|
+
holder = target.character
|
|
83
|
+
lines.append(characters.change_hp(holder, amount))
|
|
84
|
+
down = holder.hit_points_current == 0
|
|
85
|
+
elif target.combatant is not None:
|
|
86
|
+
holder = target.combatant
|
|
87
|
+
lines.append(_change_combatant_hp(campaign, holder, amount))
|
|
88
|
+
down = not holder.hit_points
|
|
89
|
+
else:
|
|
90
|
+
continue
|
|
91
|
+
if amount < 0 and (held := concentration.after_damage(holder, target.name, -amount, down)):
|
|
92
|
+
lines.append(held)
|
|
93
|
+
return lines
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _change_combatant_hp(campaign: Campaign, combatant: Combatant, amount: int) -> str:
|
|
97
|
+
if combatant.hit_points is None:
|
|
98
|
+
raise InvalidChangeError(
|
|
99
|
+
f"{combatant.name}'s hit points are not tracked: save its stat block with "
|
|
100
|
+
"save_monster (it then fights with it), then change its hit points again."
|
|
101
|
+
)
|
|
102
|
+
maximum = combatant.hit_points_max or combatant.hit_points
|
|
103
|
+
hp = rules.apply_hp_change(combatant.hit_points, maximum, 0, amount)
|
|
104
|
+
combatant.hit_points = hp.current
|
|
105
|
+
if hp.current > 0:
|
|
106
|
+
return f"{combatant.name}: HP {hp.current}/{maximum}"
|
|
107
|
+
_, acting = combat.remove(campaign.game_state, combatant.name)
|
|
108
|
+
after = f" {acting.name} acts now." if acting else " No one is left: call end_combat."
|
|
109
|
+
return f"{combatant.name} drops to 0 HP and leaves the fight.{after}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def grant_temporary_hp(campaign: Campaign, refs: Sequence[str], amount: int) -> list[str]:
|
|
113
|
+
"""Temporary hit points don't stack: each character keeps the higher value."""
|
|
114
|
+
lines = combatants.refresh(campaign)
|
|
115
|
+
for target in resolve(campaign, refs):
|
|
116
|
+
if target.character is None:
|
|
117
|
+
raise InvalidChangeError("Temporary hit points are tracked for characters only.")
|
|
118
|
+
character = target.character
|
|
119
|
+
character.temporary_hit_points = max(character.temporary_hit_points, amount)
|
|
120
|
+
character.updated_at = now()
|
|
121
|
+
lines.append(f"{character.name}: {character.temporary_hit_points} temporary HP")
|
|
122
|
+
return lines
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def set_condition(
|
|
126
|
+
campaign: Campaign,
|
|
127
|
+
refs: Sequence[str],
|
|
128
|
+
condition: str,
|
|
129
|
+
active: bool,
|
|
130
|
+
rounds: int | None = None,
|
|
131
|
+
) -> list[str]:
|
|
132
|
+
"""Add or remove one condition on each target; one line per target. *rounds*:
|
|
133
|
+
it ends by itself at the end of the target's turn that many turns on."""
|
|
134
|
+
if condition not in CONDITIONS:
|
|
135
|
+
raise InvalidChangeError(
|
|
136
|
+
f"Unknown condition '{condition}'. Conditions: {', '.join(CONDITIONS)}."
|
|
137
|
+
)
|
|
138
|
+
lines = combatants.refresh(campaign)
|
|
139
|
+
for target in resolve(campaign, refs):
|
|
140
|
+
holder = target.character or target.combatant
|
|
141
|
+
if holder is None:
|
|
142
|
+
continue
|
|
143
|
+
others = [c for c in holder.conditions if c != condition]
|
|
144
|
+
holder.conditions = [*others, condition] if active else others
|
|
145
|
+
timers = {c: n for c, n in holder.condition_rounds.items() if c != condition}
|
|
146
|
+
holder.condition_rounds = {**timers, condition: rounds} if active and rounds else timers
|
|
147
|
+
lines.append(f"{target.name}: {timed(holder) or 'no condition'}")
|
|
148
|
+
if (
|
|
149
|
+
active
|
|
150
|
+
and condition in INCAPACITATING
|
|
151
|
+
and (lost := concentration.lose(holder, target.name))
|
|
152
|
+
):
|
|
153
|
+
lines.append(lost)
|
|
154
|
+
return lines
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def end_of_turn(campaign: Campaign, combatant: Combatant) -> list[str]:
|
|
158
|
+
"""Count down the timed conditions of the creature whose turn ends."""
|
|
159
|
+
holder = campaign.characters.get(combatant.character_id or "") or combatant
|
|
160
|
+
left = {c: n - 1 for c, n in holder.condition_rounds.items() if c in holder.conditions}
|
|
161
|
+
ended = [c for c, n in left.items() if n <= 0]
|
|
162
|
+
holder.condition_rounds = {c: n for c, n in left.items() if n > 0}
|
|
163
|
+
holder.conditions = [c for c in holder.conditions if c not in ended]
|
|
164
|
+
return [f"{combatant.name}: {', '.join(ended)} ends."] if ended else []
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def timed(holder: Character | Combatant) -> str:
|
|
168
|
+
""" "stunned (1 turn left), prone": each condition, with what remains of a timed one."""
|
|
169
|
+
rounds = holder.condition_rounds
|
|
170
|
+
return ", ".join(
|
|
171
|
+
f"{c} ({rounds[c]} turn{'s' if rounds[c] > 1 else ''} left)" if c in rounds else c
|
|
172
|
+
for c in holder.conditions
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def set_concentration(campaign: Campaign, ref: str, spell: str | None) -> str:
|
|
177
|
+
"""Start or stop concentrating, for a character or a combatant."""
|
|
178
|
+
combatants.refresh(campaign)
|
|
179
|
+
(target,) = resolve(campaign, [ref])
|
|
180
|
+
holder = target.character or target.combatant
|
|
181
|
+
if holder is None:
|
|
182
|
+
raise NotFoundError(f"Unknown: '{ref}'.")
|
|
183
|
+
return concentration.concentrate(holder, target.name, spell)
|
dndkit/game/grid.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Squares on a battle map: names like "D7", areas, footprints and distances.
|
|
2
|
+
|
|
3
|
+
Columns are letters from A, rows numbers from 1, A1 at the top left. Every
|
|
4
|
+
square is 5 feet and, as in the Player's Handbook ("Playing on a Grid"), a
|
|
5
|
+
diagonal step costs 5 feet like any other, so two squares are as many steps
|
|
6
|
+
apart as the larger of their column and row differences.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from dndkit.errors import InvalidChangeError
|
|
12
|
+
from dndkit.models import Battlefield, Size
|
|
13
|
+
|
|
14
|
+
FEET = 5
|
|
15
|
+
Square = tuple[int, int] # column, row; both from 0
|
|
16
|
+
|
|
17
|
+
# Squares a side: Tiny and Small creatures still hold a whole square here.
|
|
18
|
+
SIZE_SQUARES: dict[str, int] = {
|
|
19
|
+
"tiny": 1,
|
|
20
|
+
"small": 1,
|
|
21
|
+
"medium": 1,
|
|
22
|
+
"large": 2,
|
|
23
|
+
"huge": 3,
|
|
24
|
+
"gargantuan": 4,
|
|
25
|
+
}
|
|
26
|
+
SIZE_RANK: dict[str, int] = {size: rank for rank, size in enumerate(SIZE_SQUARES)}
|
|
27
|
+
|
|
28
|
+
_NAME = re.compile(r"([A-Za-z])\s*(\d{1,2})")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def name(square: Square) -> str:
|
|
32
|
+
""" "D7" for (3, 6)."""
|
|
33
|
+
column, row = square
|
|
34
|
+
return f"{chr(ord('A') + column)}{row + 1}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse(text: str, field: Battlefield) -> Square:
|
|
38
|
+
"""The square *text* names ("d7", "D 7"), checked against the map's edges."""
|
|
39
|
+
match = _NAME.fullmatch(text.strip())
|
|
40
|
+
if match is None:
|
|
41
|
+
raise InvalidChangeError(f"'{text}' is not a square: write a column letter and a row, D7.")
|
|
42
|
+
square = (ord(match.group(1).upper()) - ord("A"), int(match.group(2)) - 1)
|
|
43
|
+
if not inside(square, field):
|
|
44
|
+
last = name((field.width - 1, field.height - 1))
|
|
45
|
+
raise InvalidChangeError(f"{name(square)} is off the map, which runs from A1 to {last}.")
|
|
46
|
+
return square
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def area(text: str, field: Battlefield) -> list[Square]:
|
|
50
|
+
"""The squares *text* names: one ("C3") or a rectangle between two corners ("C1:E2")."""
|
|
51
|
+
corners = [parse(part, field) for part in text.split(":")]
|
|
52
|
+
if len(corners) > 2:
|
|
53
|
+
raise InvalidChangeError(f"'{text}' has more than two corners.")
|
|
54
|
+
(x1, y1), (x2, y2) = corners[0], corners[-1]
|
|
55
|
+
return [
|
|
56
|
+
(x, y)
|
|
57
|
+
for y in range(min(y1, y2), max(y1, y2) + 1)
|
|
58
|
+
for x in range(min(x1, x2), max(x1, x2) + 1)
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def inside(square: Square, field: Battlefield) -> bool:
|
|
63
|
+
"""Whether *square* is on the map."""
|
|
64
|
+
column, row = square
|
|
65
|
+
return 0 <= column < field.width and 0 <= row < field.height
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def footprint(top_left: Square, size: Size) -> list[Square]:
|
|
69
|
+
"""The squares a creature of *size* holds from *top_left*."""
|
|
70
|
+
side = SIZE_SQUARES[size]
|
|
71
|
+
x, y = top_left
|
|
72
|
+
return [(x + dx, y + dy) for dy in range(side) for dx in range(side)]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def steps_between(a: list[Square], b: list[Square]) -> int:
|
|
76
|
+
"""Steps between the nearest squares of two footprints: 1 when adjacent, 0 when they meet."""
|
|
77
|
+
dx = max(
|
|
78
|
+
0, min(x for x, _ in b) - max(x for x, _ in a), min(x for x, _ in a) - max(x for x, _ in b)
|
|
79
|
+
)
|
|
80
|
+
dy = max(
|
|
81
|
+
0, min(y for _, y in b) - max(y for _, y in a), min(y for _, y in a) - max(y for _, y in b)
|
|
82
|
+
)
|
|
83
|
+
return max(dx, dy)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def feet_between(a: list[Square], b: list[Square]) -> int:
|
|
87
|
+
"""Distance in feet between two footprints, as 5e measures it on a grid."""
|
|
88
|
+
return steps_between(a, b) * FEET
|