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.
Files changed (118) hide show
  1. dndkit/__init__.py +10 -0
  2. dndkit/__main__.py +6 -0
  3. dndkit/activity.py +68 -0
  4. dndkit/app.py +105 -0
  5. dndkit/config.py +104 -0
  6. dndkit/errors.py +48 -0
  7. dndkit/game/__init__.py +1 -0
  8. dndkit/game/areas.py +181 -0
  9. dndkit/game/battlefield.py +154 -0
  10. dndkit/game/bestiary.py +155 -0
  11. dndkit/game/characters.py +227 -0
  12. dndkit/game/combat.py +209 -0
  13. dndkit/game/combatants.py +188 -0
  14. dndkit/game/concentration.py +43 -0
  15. dndkit/game/dice.py +186 -0
  16. dndkit/game/effects.py +183 -0
  17. dndkit/game/grid.py +88 -0
  18. dndkit/game/journal.py +47 -0
  19. dndkit/game/movement.py +264 -0
  20. dndkit/game/pathing.py +153 -0
  21. dndkit/game/proficiencies.py +104 -0
  22. dndkit/game/progression.py +77 -0
  23. dndkit/game/readiness.py +214 -0
  24. dndkit/game/reference.py +123 -0
  25. dndkit/game/registry.py +172 -0
  26. dndkit/game/resources.py +96 -0
  27. dndkit/game/rests.py +98 -0
  28. dndkit/game/rules.py +196 -0
  29. dndkit/game/seating.py +71 -0
  30. dndkit/game/sight.py +142 -0
  31. dndkit/game/spellbook.py +97 -0
  32. dndkit/game/tables.py +216 -0
  33. dndkit/game/world.py +125 -0
  34. dndkit/models/__init__.py +69 -0
  35. dndkit/models/base.py +26 -0
  36. dndkit/models/battle.py +40 -0
  37. dndkit/models/bestiary.py +56 -0
  38. dndkit/models/campaign.py +209 -0
  39. dndkit/models/character.py +149 -0
  40. dndkit/models/world.py +62 -0
  41. dndkit/skills/__init__.py +14 -0
  42. dndkit/skills/catalog.py +113 -0
  43. dndkit/skills/dnd-battle-map/SKILL.md +78 -0
  44. dndkit/skills/dnd-game-master/SKILL.md +125 -0
  45. dndkit/skills/dnd-monsters/SKILL.md +85 -0
  46. dndkit/skills/extension.py +93 -0
  47. dndkit/storage/__init__.py +6 -0
  48. dndkit/storage/catalog.py +72 -0
  49. dndkit/storage/confirmations.py +54 -0
  50. dndkit/storage/files.py +55 -0
  51. dndkit/storage/sharing.py +67 -0
  52. dndkit/storage/store.py +278 -0
  53. dndkit/text.py +13 -0
  54. dndkit/tools/__init__.py +47 -0
  55. dndkit/tools/apps.py +47 -0
  56. dndkit/tools/battle.py +117 -0
  57. dndkit/tools/bestiary.py +121 -0
  58. dndkit/tools/campaign.py +208 -0
  59. dndkit/tools/characters.py +189 -0
  60. dndkit/tools/combat.py +169 -0
  61. dndkit/tools/deps.py +42 -0
  62. dndkit/tools/journal.py +102 -0
  63. dndkit/tools/locations.py +98 -0
  64. dndkit/tools/npcs.py +101 -0
  65. dndkit/tools/play.py +118 -0
  66. dndkit/tools/players.py +44 -0
  67. dndkit/tools/progression.py +41 -0
  68. dndkit/tools/quests.py +95 -0
  69. dndkit/tools/recorder.py +63 -0
  70. dndkit/tools/render.py +281 -0
  71. dndkit/tools/render_combat.py +121 -0
  72. dndkit/tools/render_map.py +141 -0
  73. dndkit/tools/resources.py +104 -0
  74. dndkit/tools/schema.py +47 -0
  75. dndkit/tools/screen.py +47 -0
  76. dndkit/tools/spells.py +67 -0
  77. dndkit/tools/utility.py +42 -0
  78. dndkit/ui/__init__.py +52 -0
  79. dndkit/ui/backstage.js +41 -0
  80. dndkit/ui/battlemap.js +70 -0
  81. dndkit/ui/character.html +23 -0
  82. dndkit/ui/combat.html +18 -0
  83. dndkit/ui/components.css +283 -0
  84. dndkit/ui/components.js +261 -0
  85. dndkit/ui/desk.js +91 -0
  86. dndkit/ui/dice.html +42 -0
  87. dndkit/ui/dicetray.js +99 -0
  88. dndkit/ui/gm.html +59 -0
  89. dndkit/ui/party.html +18 -0
  90. dndkit/ui/player.html +61 -0
  91. dndkit/ui/seating.html +18 -0
  92. dndkit/ui/shared.css +195 -0
  93. dndkit/ui/shared.js +242 -0
  94. dndkit/ui/sheet.js +75 -0
  95. dndkit/ui/table.html +41 -0
  96. dndkit/ui/words.js +120 -0
  97. dndkit/ui/world.js +56 -0
  98. dndkit/views/__init__.py +7 -0
  99. dndkit/views/backstage.py +49 -0
  100. dndkit/views/battle.py +92 -0
  101. dndkit/views/desk.py +76 -0
  102. dndkit/views/everyone.py +194 -0
  103. dndkit/views/public.py +89 -0
  104. dndkit/views/widgets.py +58 -0
  105. dndkit/views/world.py +70 -0
  106. dndkit/web/__init__.py +10 -0
  107. dndkit/web/access.py +136 -0
  108. dndkit/web/audiences.py +111 -0
  109. dndkit/web/changes.py +84 -0
  110. dndkit/web/feed.py +112 -0
  111. dndkit/web/routes.py +134 -0
  112. dndkit/web/server.py +131 -0
  113. dndkit-0.4.0.dist-info/METADATA +567 -0
  114. dndkit-0.4.0.dist-info/RECORD +118 -0
  115. dndkit-0.4.0.dist-info/WHEEL +5 -0
  116. dndkit-0.4.0.dist-info/entry_points.txt +2 -0
  117. dndkit-0.4.0.dist-info/licenses/LICENSE +22 -0
  118. 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
+ )
@@ -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)