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/rests.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Short and long rests (PHB ch. 8), and the Hit Dice spent and regained."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from dndkit.errors import InvalidChangeError
|
|
8
|
+
from dndkit.game import characters, dice, registry, rules
|
|
9
|
+
from dndkit.game.dice import RollResult
|
|
10
|
+
from dndkit.game.resources import reset_death_saves
|
|
11
|
+
from dndkit.models import Campaign, Character, now
|
|
12
|
+
|
|
13
|
+
RestKind = Literal["short", "long"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Rested:
|
|
18
|
+
"""What a rest did: a line per character, and the Hit Dice rolled."""
|
|
19
|
+
|
|
20
|
+
lines: list[str]
|
|
21
|
+
rolls: list[RollResult]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def rest(campaign: Campaign, refs: Sequence[str], kind: RestKind, hit_dice: int = 0) -> Rested:
|
|
25
|
+
"""A rest for the named characters, or everyone when *refs* is empty. On a short
|
|
26
|
+
rest each spends *hit_dice* Hit Dice to heal."""
|
|
27
|
+
if hit_dice and kind == "long":
|
|
28
|
+
raise InvalidChangeError("Hit Dice are spent on a short rest; a long rest heals fully.")
|
|
29
|
+
targets = (
|
|
30
|
+
registry.find_many(campaign.characters, refs, characters.KIND)
|
|
31
|
+
if refs
|
|
32
|
+
else list(campaign.characters.values())
|
|
33
|
+
)
|
|
34
|
+
if kind == "long":
|
|
35
|
+
return Rested([_long_rest(c) for c in targets], [])
|
|
36
|
+
spent = [_spend(c, hit_dice) for c in targets] if hit_dice else []
|
|
37
|
+
lines = [line for line, _ in spent] or [_short_rest(c) for c in targets]
|
|
38
|
+
if spent:
|
|
39
|
+
lines += [line for c in targets if (line := _pact_slots(c))]
|
|
40
|
+
return Rested(lines, [roll for _, roll in spent])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _long_rest(character: Character) -> str:
|
|
44
|
+
"""Needs at least 1 HP when the rest starts; half the Hit Dice come back."""
|
|
45
|
+
if character.hit_points_current == 0:
|
|
46
|
+
return f"{character.name} is at 0 HP and gains nothing from the rest"
|
|
47
|
+
cls = character.character_class
|
|
48
|
+
left = characters.hit_dice_left(character)
|
|
49
|
+
regained = min(cls.level - left, max(1, cls.level // 2))
|
|
50
|
+
character.hit_dice_remaining = f"{left + regained}d{rules.hit_die(cls.name)}"
|
|
51
|
+
character.exhaustion = max(0, character.exhaustion - 1) # first: it may lift the halving
|
|
52
|
+
character.hit_points_current = character.hp_maximum
|
|
53
|
+
character.temporary_hit_points = 0
|
|
54
|
+
character.spell_slots_used = {}
|
|
55
|
+
reset_death_saves(character)
|
|
56
|
+
character.updated_at = now()
|
|
57
|
+
exhaustion = f", exhaustion {character.exhaustion}" if character.exhaustion else ""
|
|
58
|
+
return (
|
|
59
|
+
f"{character.name}: HP {character.hp_maximum}, spell slots restored, "
|
|
60
|
+
f"Hit Dice {character.hit_dice_remaining}{exhaustion}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _short_rest(character: Character) -> str:
|
|
65
|
+
"""Warlocks get their pact slots back; anyone may spend Hit Dice."""
|
|
66
|
+
pact = _pact_slots(character)
|
|
67
|
+
left = f"may spend Hit Dice to heal ({character.hit_dice_remaining} left: rest with hit_dice)"
|
|
68
|
+
return f"{pact}; {left}" if pact else f"{character.name}: {left}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _pact_slots(character: Character) -> str | None:
|
|
72
|
+
if rules.class_key(character.character_class.name) != "warlock":
|
|
73
|
+
return None
|
|
74
|
+
character.spell_slots_used = {}
|
|
75
|
+
character.updated_at = now()
|
|
76
|
+
return f"{character.name}: pact slots restored"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _spend(character: Character, count: int) -> tuple[str, RollResult]:
|
|
80
|
+
"""Roll *count* Hit Dice; each heals its roll plus the Constitution modifier."""
|
|
81
|
+
left = characters.hit_dice_left(character)
|
|
82
|
+
if count > left:
|
|
83
|
+
raise InvalidChangeError(f"{character.name} has {left} Hit Dice left.")
|
|
84
|
+
die = rules.hit_die(character.character_class.name)
|
|
85
|
+
con = character.abilities["constitution"].mod if "constitution" in character.abilities else 0
|
|
86
|
+
result = dice.roll(f"{count}d{die}")
|
|
87
|
+
healed = sum(max(0, face + con) for face in result.terms[0].faces)
|
|
88
|
+
character.hit_dice_remaining = f"{left - count}d{die}"
|
|
89
|
+
status = (
|
|
90
|
+
characters.change_hp(character, healed)
|
|
91
|
+
if healed
|
|
92
|
+
else f"{character.name}: HP {character.hit_points_current}/{character.hp_maximum}"
|
|
93
|
+
)
|
|
94
|
+
return (
|
|
95
|
+
f"{status}; spent {count}d{die} {list(result.terms[0].faces)} with CON {con:+d}: "
|
|
96
|
+
f"+{healed} HP, {left - count} Hit Dice left",
|
|
97
|
+
result,
|
|
98
|
+
)
|
dndkit/game/rules.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""D&D 5e rules the tools apply: hit points, experience, levels, encounters."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from dndkit.game.tables import (
|
|
6
|
+
CASTING_ABILITY,
|
|
7
|
+
CLASS_ALIASES,
|
|
8
|
+
CLASS_SAVES,
|
|
9
|
+
CLASSES,
|
|
10
|
+
DIFFICULTY_THRESHOLDS,
|
|
11
|
+
ENCOUNTER_MULTIPLIERS,
|
|
12
|
+
FULL_CASTER_SLOTS,
|
|
13
|
+
MONSTER_STATS,
|
|
14
|
+
PACT_SLOTS,
|
|
15
|
+
SLOW_RACES,
|
|
16
|
+
XP_BY_CHALLENGE,
|
|
17
|
+
XP_FOR_LEVEL,
|
|
18
|
+
)
|
|
19
|
+
from dndkit.models import ABILITIES
|
|
20
|
+
from dndkit.text import normalize
|
|
21
|
+
|
|
22
|
+
_FRENCH_ABILITIES = {"for": "strength", "sag": "wisdom"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class HitPoints:
|
|
27
|
+
"""Hit points after a change."""
|
|
28
|
+
|
|
29
|
+
current: int
|
|
30
|
+
temporary: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def apply_hp_change(current: int, maximum: int, temporary: int, delta: int) -> HitPoints:
|
|
34
|
+
"""Healing up to the maximum, or damage taken from temporary hit points first.
|
|
35
|
+
|
|
36
|
+
Healing never restores temporary hit points (PHB, "Temporary Hit Points").
|
|
37
|
+
"""
|
|
38
|
+
if delta >= 0:
|
|
39
|
+
return HitPoints(current=min(maximum, current + delta), temporary=temporary)
|
|
40
|
+
damage = -delta
|
|
41
|
+
absorbed = min(temporary, damage)
|
|
42
|
+
return HitPoints(current=max(0, current - (damage - absorbed)), temporary=temporary - absorbed)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def experience_per_character(total_xp: int, party_size: int) -> int:
|
|
46
|
+
"""XP each character earns: the monsters' total XP split evenly (DMG, ch. 3).
|
|
47
|
+
|
|
48
|
+
The DMG's encounter multipliers rate an encounter's difficulty; they never
|
|
49
|
+
change the XP awarded.
|
|
50
|
+
"""
|
|
51
|
+
if party_size < 1:
|
|
52
|
+
raise ValueError("party_size must be at least 1")
|
|
53
|
+
return total_xp // party_size
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def base_speed(race: str) -> int:
|
|
57
|
+
"""Walking speed for a race: 25 for dwarves, halflings and gnomes, else 30."""
|
|
58
|
+
words = set(normalize(race).split())
|
|
59
|
+
return 25 if words & SLOW_RACES else 30
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def class_key(class_name: str) -> str | None:
|
|
63
|
+
"""The 5e class a name refers to ("Magicien" -> "wizard"), or None if homebrew."""
|
|
64
|
+
name = normalize(class_name)
|
|
65
|
+
key = CLASS_ALIASES.get(name, name)
|
|
66
|
+
return key if key in CLASSES else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def hit_die(class_name: str) -> int:
|
|
70
|
+
"""The class's hit die size; d8 for a class the rules do not know."""
|
|
71
|
+
key = class_key(class_name)
|
|
72
|
+
return CLASSES[key][0] if key else 8
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def proficiency_bonus(level: int) -> int:
|
|
76
|
+
"""+2 at levels 1-4, up to +6 at 17-20."""
|
|
77
|
+
return 2 + (max(1, level) - 1) // 4
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def level_for_xp(xp: int) -> int:
|
|
81
|
+
"""The highest level *xp* experience points reach."""
|
|
82
|
+
return max(level for level, needed in XP_FOR_LEVEL.items() if xp >= needed)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def challenge_key(challenge: str) -> str:
|
|
86
|
+
"""A challenge rating as the tables write it ("0.25" -> "1/4")."""
|
|
87
|
+
text = challenge.strip().replace(",", ".")
|
|
88
|
+
return {"0.125": "1/8", "0.25": "1/4", "0.5": "1/2"}.get(text, text)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def xp_for_challenge(challenge: str) -> int | None:
|
|
92
|
+
"""Monster XP for a challenge rating ("1/4", "0.25", "3"), None if unknown."""
|
|
93
|
+
return XP_BY_CHALLENGE.get(challenge_key(challenge))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class MonsterStats:
|
|
98
|
+
"""What a monster of some challenge rating should have (DMG ch. 9)."""
|
|
99
|
+
|
|
100
|
+
armor_class: int
|
|
101
|
+
hit_points: int
|
|
102
|
+
attack_bonus: int
|
|
103
|
+
damage_per_round: int
|
|
104
|
+
save_dc: int
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def monster_stats(challenge: str) -> MonsterStats | None:
|
|
108
|
+
"""The DMG's statistics for a challenge rating, the middle of each range; None if unknown."""
|
|
109
|
+
row = MONSTER_STATS.get(challenge_key(challenge))
|
|
110
|
+
if row is None:
|
|
111
|
+
return None
|
|
112
|
+
armor, (low_hp, high_hp), attack, (low_damage, high_damage), save = row
|
|
113
|
+
return MonsterStats(
|
|
114
|
+
armor, (low_hp + high_hp) // 2, attack, (low_damage + high_damage) // 2, save
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def starting_hit_points(class_name: str, level: int, constitution_mod: int) -> int:
|
|
119
|
+
"""Hit points by the PHB: the whole hit die at level 1, then its average; CON each level."""
|
|
120
|
+
die = hit_die(class_name)
|
|
121
|
+
return max(1, die + constitution_mod + (level - 1) * (die // 2 + 1 + constitution_mod))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def casting_ability(class_name: str) -> str | None:
|
|
125
|
+
"""The ability a class casts with ("Magicien" -> "intelligence"); None for non-casters."""
|
|
126
|
+
key = class_key(class_name)
|
|
127
|
+
return CASTING_ABILITY.get(key) if key else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def spell_slots_for(class_name: str, level: int) -> dict[int, int] | None:
|
|
131
|
+
"""Spell slots by slot level for a single-class character; None for non-casters."""
|
|
132
|
+
key = class_key(class_name)
|
|
133
|
+
progression = CLASSES[key][1] if key else "none"
|
|
134
|
+
if progression == "full":
|
|
135
|
+
return dict(enumerate(FULL_CASTER_SLOTS[level], start=1))
|
|
136
|
+
if progression in ("half", "half_up"):
|
|
137
|
+
if progression == "half" and level < 2:
|
|
138
|
+
return {}
|
|
139
|
+
return dict(enumerate(FULL_CASTER_SLOTS[(level + 1) // 2], start=1))
|
|
140
|
+
if progression == "pact":
|
|
141
|
+
count, slot_level = PACT_SLOTS[level]
|
|
142
|
+
return {slot_level: count}
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class Difficulty:
|
|
148
|
+
"""An encounter rated against a party (DMG ch. 3, "Creating a Combat Encounter")."""
|
|
149
|
+
|
|
150
|
+
total_xp: int
|
|
151
|
+
multiplier: float
|
|
152
|
+
adjusted_xp: int
|
|
153
|
+
thresholds: tuple[int, int, int, int] # easy, medium, hard, deadly for the party
|
|
154
|
+
rating: str # trivial, easy, medium, hard, deadly
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def encounter_difficulty(party_levels: list[int], monster_xps: list[int]) -> Difficulty:
|
|
158
|
+
"""Rate monsters against a party: XP times a count multiplier, against thresholds."""
|
|
159
|
+
if not party_levels:
|
|
160
|
+
raise ValueError("the party has no characters")
|
|
161
|
+
per_level = [DIFFICULTY_THRESHOLDS[min(20, max(1, level))] for level in party_levels]
|
|
162
|
+
easy, medium, hard, deadly = (sum(row[i] for row in per_level) for i in range(4))
|
|
163
|
+
step = _multiplier_step(len(monster_xps))
|
|
164
|
+
if len(party_levels) < 3:
|
|
165
|
+
step += 1
|
|
166
|
+
elif len(party_levels) >= 6:
|
|
167
|
+
step -= 1
|
|
168
|
+
multiplier = ENCOUNTER_MULTIPLIERS[max(0, min(step, len(ENCOUNTER_MULTIPLIERS) - 1))]
|
|
169
|
+
total = sum(monster_xps)
|
|
170
|
+
adjusted = int(total * multiplier) if monster_xps else 0
|
|
171
|
+
ratings = ((deadly, "deadly"), (hard, "hard"), (medium, "medium"), (easy, "easy"))
|
|
172
|
+
rating = next((name for limit, name in ratings if adjusted >= limit), "trivial")
|
|
173
|
+
return Difficulty(total, multiplier, adjusted, (easy, medium, hard, deadly), rating)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _multiplier_step(monsters: int) -> int:
|
|
177
|
+
"""Index into ENCOUNTER_MULTIPLIERS for a monster count, before party size."""
|
|
178
|
+
for step, upper in enumerate((0, 1, 2, 6, 10, 14)):
|
|
179
|
+
if monsters <= upper:
|
|
180
|
+
return step
|
|
181
|
+
return 6
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def ability_name(text: str | None) -> str | None:
|
|
185
|
+
""" "INT", "Intelligence" or French "Sagesse" -> the ability's name."""
|
|
186
|
+
if not text:
|
|
187
|
+
return None
|
|
188
|
+
wanted = normalize(text)[:3]
|
|
189
|
+
found = next((a for a in ABILITIES if a.startswith(wanted)), None)
|
|
190
|
+
return found or _FRENCH_ABILITIES.get(wanted)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def class_saves(class_name: str) -> list[str]:
|
|
194
|
+
"""The saving throws a class is proficient in; none for a class the rules do not know."""
|
|
195
|
+
key = class_key(class_name)
|
|
196
|
+
return list(CLASS_SAVES[key]) if key else []
|
dndkit/game/seating.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Who sits at the table tonight, and which character each plays.
|
|
2
|
+
|
|
3
|
+
A player is a person, named as the voice client names their voice: roomkit-ui
|
|
4
|
+
opens each spoken turn with the speaker's name. A character's
|
|
5
|
+
``player_name`` ties it to its player. The roll call (``seat``) says who is
|
|
6
|
+
present; until there is one, the whole party counts as present.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dndkit.errors import InvalidChangeError, NotFoundError
|
|
10
|
+
from dndkit.game import characters
|
|
11
|
+
from dndkit.models import Campaign, Character, now
|
|
12
|
+
from dndkit.text import normalize
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def same_person(a: str | None, b: str | None) -> bool:
|
|
16
|
+
"""Two spellings of one name (case and accents aside)."""
|
|
17
|
+
return a is not None and b is not None and normalize(a) == normalize(b)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def characters_of(campaign: Campaign, player: str) -> list[Character]:
|
|
21
|
+
"""The characters *player* plays, by name."""
|
|
22
|
+
played = [c for c in campaign.characters.values() if same_person(c.player_name, player)]
|
|
23
|
+
return sorted(played, key=lambda c: c.name)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_present(campaign: Campaign, character: Character) -> bool:
|
|
27
|
+
"""Whether *character*'s player answered the roll call; True when there was none."""
|
|
28
|
+
present = campaign.game_state.players_present
|
|
29
|
+
return not present or any(same_person(character.player_name, p) for p in present)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def present_characters(campaign: Campaign) -> list[Character]:
|
|
33
|
+
"""The characters at the table: the whole party until a roll call."""
|
|
34
|
+
return [c for c in campaign.characters.values() if is_present(campaign, c)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def seat(campaign: Campaign, player: str, character_ref: str | None) -> str | None:
|
|
38
|
+
"""Seat *player*, with the character they play if given.
|
|
39
|
+
|
|
40
|
+
Returns a note when the character changes hands, for the model to say.
|
|
41
|
+
"""
|
|
42
|
+
name = " ".join(player.split())
|
|
43
|
+
if not name:
|
|
44
|
+
raise InvalidChangeError("A player needs a name.")
|
|
45
|
+
note = None
|
|
46
|
+
if character_ref:
|
|
47
|
+
character = characters.find(campaign, character_ref)
|
|
48
|
+
previous = character.player_name
|
|
49
|
+
if previous and not same_person(previous, name):
|
|
50
|
+
note = f"{character.name} was played by {previous}, now by {name}."
|
|
51
|
+
character.player_name = name
|
|
52
|
+
character.updated_at = now()
|
|
53
|
+
state = campaign.game_state
|
|
54
|
+
if not any(same_person(p, name) for p in state.players_present):
|
|
55
|
+
state.players_present = [*state.players_present, name]
|
|
56
|
+
return note
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def leave(campaign: Campaign, player: str) -> None:
|
|
60
|
+
"""Take *player* off the table; their characters stay in the party."""
|
|
61
|
+
state = campaign.game_state
|
|
62
|
+
seated = next((p for p in state.players_present if same_person(p, player)), None)
|
|
63
|
+
if seated is None:
|
|
64
|
+
at_table = ", ".join(state.players_present) or "nobody"
|
|
65
|
+
raise NotFoundError(f"{player} is not at the table. At the table: {at_table}.")
|
|
66
|
+
state.players_present = [p for p in state.players_present if p != seated]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def clear(campaign: Campaign) -> None:
|
|
70
|
+
"""Forget the last roll call: a new session starts."""
|
|
71
|
+
campaign.game_state.players_present = []
|
dndkit/game/sight.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Lines across the battle map: what stops an area of effect, and cover.
|
|
2
|
+
|
|
3
|
+
Points are in feet, the map's top-left corner at (0, 0): square (x, y) spans
|
|
4
|
+
5x to 5x+5 across and 5y to 5y+5 down. A line along a square's edge or
|
|
5
|
+
through its corner passes; one crossing its inside is blocked.
|
|
6
|
+
|
|
7
|
+
Cover follows the DMG (ch. 8, "Cover" on a grid): from the attacker's best
|
|
8
|
+
corner, lines to the four corners of the target's square; one or two blocked
|
|
9
|
+
by an obstacle give half cover, three or four three-quarters, and a target
|
|
10
|
+
walled off from every corner has total cover. Creatures in the way give half
|
|
11
|
+
cover at most.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from dndkit.errors import InvalidChangeError
|
|
19
|
+
from dndkit.game import battlefield, combat, grid, pathing
|
|
20
|
+
from dndkit.game.grid import Square
|
|
21
|
+
from dndkit.models import Campaign, Combatant, GameState
|
|
22
|
+
|
|
23
|
+
Point = tuple[float, float]
|
|
24
|
+
EDGE = 0.01 # how far inside a square a line must go to be blocked
|
|
25
|
+
COVER = ("none", "half", "three-quarters", "total")
|
|
26
|
+
COVER_BONUS = {"half": 2, "three-quarters": 5}
|
|
27
|
+
_SIDES = ((-1, 0), (1, 0), (0, -1), (0, 1)) # left, right, top, bottom
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def centre(squares: Iterable[Square]) -> Point:
|
|
31
|
+
"""The middle of the squares, in feet."""
|
|
32
|
+
held = list(squares)
|
|
33
|
+
xs = [x for x, _ in held]
|
|
34
|
+
ys = [y for _, y in held]
|
|
35
|
+
return ((min(xs) + max(xs) + 1) * grid.FEET / 2, (min(ys) + max(ys) + 1) * grid.FEET / 2)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def corners(square: Square) -> list[Point]:
|
|
39
|
+
"""A square's four corners, in feet."""
|
|
40
|
+
x, y = square[0] * grid.FEET, square[1] * grid.FEET
|
|
41
|
+
return [(x, y), (x + grid.FEET, y), (x, y + grid.FEET), (x + grid.FEET, y + grid.FEET)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def crosses(
|
|
45
|
+
a: Point, b: Point, square: Square, solid: set[Square] | frozenset[Square] = frozenset()
|
|
46
|
+
) -> bool:
|
|
47
|
+
"""Whether the segment a-b goes through the inside of *square* (Liang-Barsky).
|
|
48
|
+
|
|
49
|
+
A side shared with another *solid* square is no edge: a line running along
|
|
50
|
+
the seam between two wall squares is inside the wall.
|
|
51
|
+
"""
|
|
52
|
+
x, y = square
|
|
53
|
+
inset = [EDGE if (x + dx, y + dy) not in solid else -EDGE for dx, dy in _SIDES]
|
|
54
|
+
x0, x1 = x * grid.FEET + inset[0], (x + 1) * grid.FEET - inset[1]
|
|
55
|
+
y0, y1 = y * grid.FEET + inset[2], (y + 1) * grid.FEET - inset[3]
|
|
56
|
+
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
57
|
+
start, end = 0.0, 1.0
|
|
58
|
+
for p, q in ((-dx, a[0] - x0), (dx, x1 - a[0]), (-dy, a[1] - y0), (dy, y1 - a[1])):
|
|
59
|
+
if p == 0:
|
|
60
|
+
if q < 0:
|
|
61
|
+
return False
|
|
62
|
+
continue
|
|
63
|
+
t = q / p
|
|
64
|
+
if p < 0:
|
|
65
|
+
start = max(start, t)
|
|
66
|
+
else:
|
|
67
|
+
end = min(end, t)
|
|
68
|
+
if start > end:
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def blocked(a: Point, b: Point, obstacles: set[Square]) -> bool:
|
|
74
|
+
"""Whether any of *obstacles* stands across the segment a-b."""
|
|
75
|
+
left, right = sorted((a[0], b[0]))
|
|
76
|
+
top, bottom = sorted((a[1], b[1]))
|
|
77
|
+
columns = range(math.floor(left / grid.FEET) - 1, math.floor(right / grid.FEET) + 2)
|
|
78
|
+
rows = range(math.floor(top / grid.FEET) - 1, math.floor(bottom / grid.FEET) + 2)
|
|
79
|
+
return any(
|
|
80
|
+
(x, y) in obstacles and crosses(a, b, (x, y), obstacles) for x in columns for y in rows
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cover(state: GameState, attacker: Combatant, target: Combatant) -> str:
|
|
85
|
+
"""The cover *target* has against *attacker*: none, half, three-quarters or total."""
|
|
86
|
+
walls = battlefield.wall_squares(battlefield.require(state))
|
|
87
|
+
mine = battlefield.footprint_of(state, attacker) or []
|
|
88
|
+
theirs = battlefield.footprint_of(state, target) or []
|
|
89
|
+
bodies = {
|
|
90
|
+
s
|
|
91
|
+
for other, held in battlefield.others(state, attacker)
|
|
92
|
+
if other is not target
|
|
93
|
+
for s in held
|
|
94
|
+
}
|
|
95
|
+
best = len(COVER) - 1
|
|
96
|
+
for corner in {point for square in mine for point in corners(square)}:
|
|
97
|
+
for square in theirs:
|
|
98
|
+
lines = [(corner, end) for end in corners(square)]
|
|
99
|
+
by_walls = sum(blocked(a, b, walls) for a, b in lines)
|
|
100
|
+
by_bodies = any(blocked(a, b, bodies) for a, b in lines)
|
|
101
|
+
if by_walls == 4:
|
|
102
|
+
level = 3
|
|
103
|
+
elif by_walls >= 3:
|
|
104
|
+
level = 2
|
|
105
|
+
else:
|
|
106
|
+
level = 1 if by_walls or by_bodies else 0
|
|
107
|
+
best = min(best, level)
|
|
108
|
+
return COVER[best]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class Sightline:
|
|
113
|
+
"""What bears on an attack from one combatant at another."""
|
|
114
|
+
|
|
115
|
+
attacker: Combatant
|
|
116
|
+
target: Combatant
|
|
117
|
+
feet: int
|
|
118
|
+
cover: str
|
|
119
|
+
in_reach: bool
|
|
120
|
+
pressed_by: list[str] # foes adjacent to the attacker: its ranged attacks have disadvantage
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def look(campaign: Campaign, attacker_name: str, target_name: str) -> Sightline:
|
|
124
|
+
"""Distance, cover and what else bears on *attacker_name* attacking *target_name*."""
|
|
125
|
+
state = campaign.game_state
|
|
126
|
+
attacker = combat.find(state, attacker_name)
|
|
127
|
+
target = combat.find(state, target_name)
|
|
128
|
+
mine = battlefield.footprint_of(state, attacker)
|
|
129
|
+
theirs = battlefield.footprint_of(state, target)
|
|
130
|
+
if mine is None or theirs is None:
|
|
131
|
+
raise InvalidChangeError("Both must be on the map: place_tokens first.")
|
|
132
|
+
feet = grid.feet_between(mine, theirs)
|
|
133
|
+
pressed = [
|
|
134
|
+
other.name
|
|
135
|
+
for other, held in battlefield.others(state, attacker)
|
|
136
|
+
if battlefield.is_party(other) != battlefield.is_party(attacker)
|
|
137
|
+
and grid.feet_between(mine, held) <= grid.FEET
|
|
138
|
+
and pathing.can_react(campaign, other)
|
|
139
|
+
]
|
|
140
|
+
return Sightline(
|
|
141
|
+
attacker, target, feet, cover(state, attacker, target), feet <= attacker.reach, pressed
|
|
142
|
+
)
|
dndkit/game/spellbook.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Spell slots and the spells a character knows."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from dndkit.errors import InvalidChangeError, NotFoundError
|
|
7
|
+
from dndkit.game import characters, registry
|
|
8
|
+
from dndkit.models import Campaign, Character, Spell, now
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def set_spell_slots(campaign: Campaign, ref: str, level: int, max_slots: int) -> Character:
|
|
12
|
+
"""Set how many slots of *level* a character has; 0 removes the level."""
|
|
13
|
+
character = characters.find(campaign, ref)
|
|
14
|
+
if max_slots == 0:
|
|
15
|
+
character.spell_slots.pop(level, None)
|
|
16
|
+
character.spell_slots_used.pop(level, None)
|
|
17
|
+
else:
|
|
18
|
+
character.spell_slots[level] = max_slots
|
|
19
|
+
used = character.spell_slots_used.get(level, 0)
|
|
20
|
+
character.spell_slots_used[level] = min(used, max_slots)
|
|
21
|
+
character.updated_at = now()
|
|
22
|
+
return character
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def use_spell_slot(campaign: Campaign, ref: str, level: int) -> tuple[Character, int]:
|
|
26
|
+
"""Spend one slot of *level*; returns the character and the slots left."""
|
|
27
|
+
character = characters.find(campaign, ref)
|
|
28
|
+
maximum = character.spell_slots.get(level, 0)
|
|
29
|
+
if maximum == 0:
|
|
30
|
+
raise InvalidChangeError(
|
|
31
|
+
f"{character.name} has no level {level} spell slots. "
|
|
32
|
+
f"Slots: {describe_slots(character)}."
|
|
33
|
+
)
|
|
34
|
+
used = character.spell_slots_used.get(level, 0)
|
|
35
|
+
if used >= maximum:
|
|
36
|
+
raise InvalidChangeError(
|
|
37
|
+
f"{character.name} has no level {level} slot left ({used}/{maximum} used)."
|
|
38
|
+
)
|
|
39
|
+
character.spell_slots_used[level] = used + 1
|
|
40
|
+
character.updated_at = now()
|
|
41
|
+
return character, maximum - used - 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def describe_slots(character: Character) -> str:
|
|
45
|
+
"""Slots left per level, e.g. "level 1: 2/4, level 2: 1/2"."""
|
|
46
|
+
if not character.spell_slots:
|
|
47
|
+
return "none"
|
|
48
|
+
return ", ".join(
|
|
49
|
+
f"level {level}: {maximum - character.spell_slots_used.get(level, 0)}/{maximum}"
|
|
50
|
+
for level, maximum in sorted(character.spell_slots.items())
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def set_known_spell(
|
|
55
|
+
campaign: Campaign, ref: str, spell_name: str, changes: Mapping[str, Any]
|
|
56
|
+
) -> tuple[Character, Spell, bool]:
|
|
57
|
+
"""Add a spell to a character's list, or change one already there.
|
|
58
|
+
|
|
59
|
+
Returns the character, the spell and whether it is new. A new spell needs
|
|
60
|
+
its level (0 for a cantrip).
|
|
61
|
+
"""
|
|
62
|
+
character = characters.find(campaign, ref)
|
|
63
|
+
spell = _known(character, spell_name)
|
|
64
|
+
if spell is None:
|
|
65
|
+
if changes.get("level") is None:
|
|
66
|
+
raise InvalidChangeError(
|
|
67
|
+
f"{character.name} doesn't know '{spell_name}' yet: give its level "
|
|
68
|
+
"(0 for a cantrip) to add it."
|
|
69
|
+
)
|
|
70
|
+
spell = Spell(name=spell_name.strip(), **changes)
|
|
71
|
+
character.spells_known = [*character.spells_known, spell]
|
|
72
|
+
created = True
|
|
73
|
+
else:
|
|
74
|
+
updated = registry.revised(spell, changes)
|
|
75
|
+
character.spells_known = [updated if s is spell else s for s in character.spells_known]
|
|
76
|
+
spell, created = updated, False
|
|
77
|
+
character.updated_at = now()
|
|
78
|
+
return character, spell, created
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def forget_spell(campaign: Campaign, ref: str, spell_name: str) -> tuple[Character, Spell]:
|
|
82
|
+
"""Remove a spell from a character's list."""
|
|
83
|
+
character = characters.find(campaign, ref)
|
|
84
|
+
spell = _known(character, spell_name)
|
|
85
|
+
if spell is None:
|
|
86
|
+
raise NotFoundError(
|
|
87
|
+
f"{character.name} doesn't know '{spell_name}'. "
|
|
88
|
+
f"Known spells: {registry.known_names(character.spells_known)}."
|
|
89
|
+
)
|
|
90
|
+
character.spells_known = [s for s in character.spells_known if s is not spell]
|
|
91
|
+
character.updated_at = now()
|
|
92
|
+
return character, spell
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _known(character: Character, name: str) -> Spell | None:
|
|
96
|
+
wanted = registry.normalize(name)
|
|
97
|
+
return next((s for s in character.spells_known if registry.normalize(s.name) == wanted), None)
|