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
@@ -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