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