dndkit 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dndkit/__init__.py +10 -0
- dndkit/__main__.py +6 -0
- dndkit/activity.py +68 -0
- dndkit/app.py +105 -0
- dndkit/config.py +104 -0
- dndkit/errors.py +48 -0
- dndkit/game/__init__.py +1 -0
- dndkit/game/areas.py +181 -0
- dndkit/game/battlefield.py +154 -0
- dndkit/game/bestiary.py +155 -0
- dndkit/game/characters.py +227 -0
- dndkit/game/combat.py +209 -0
- dndkit/game/combatants.py +188 -0
- dndkit/game/concentration.py +43 -0
- dndkit/game/dice.py +186 -0
- dndkit/game/effects.py +183 -0
- dndkit/game/grid.py +88 -0
- dndkit/game/journal.py +47 -0
- dndkit/game/movement.py +264 -0
- dndkit/game/pathing.py +153 -0
- dndkit/game/proficiencies.py +104 -0
- dndkit/game/progression.py +77 -0
- dndkit/game/readiness.py +214 -0
- dndkit/game/reference.py +123 -0
- dndkit/game/registry.py +172 -0
- dndkit/game/resources.py +96 -0
- dndkit/game/rests.py +98 -0
- dndkit/game/rules.py +196 -0
- dndkit/game/seating.py +71 -0
- dndkit/game/sight.py +142 -0
- dndkit/game/spellbook.py +97 -0
- dndkit/game/tables.py +216 -0
- dndkit/game/world.py +125 -0
- dndkit/models/__init__.py +69 -0
- dndkit/models/base.py +26 -0
- dndkit/models/battle.py +40 -0
- dndkit/models/bestiary.py +56 -0
- dndkit/models/campaign.py +209 -0
- dndkit/models/character.py +149 -0
- dndkit/models/world.py +62 -0
- dndkit/skills/__init__.py +14 -0
- dndkit/skills/catalog.py +113 -0
- dndkit/skills/dnd-battle-map/SKILL.md +78 -0
- dndkit/skills/dnd-game-master/SKILL.md +125 -0
- dndkit/skills/dnd-monsters/SKILL.md +85 -0
- dndkit/skills/extension.py +93 -0
- dndkit/storage/__init__.py +6 -0
- dndkit/storage/catalog.py +72 -0
- dndkit/storage/confirmations.py +54 -0
- dndkit/storage/files.py +55 -0
- dndkit/storage/sharing.py +67 -0
- dndkit/storage/store.py +278 -0
- dndkit/text.py +13 -0
- dndkit/tools/__init__.py +47 -0
- dndkit/tools/apps.py +47 -0
- dndkit/tools/battle.py +117 -0
- dndkit/tools/bestiary.py +121 -0
- dndkit/tools/campaign.py +208 -0
- dndkit/tools/characters.py +189 -0
- dndkit/tools/combat.py +169 -0
- dndkit/tools/deps.py +42 -0
- dndkit/tools/journal.py +102 -0
- dndkit/tools/locations.py +98 -0
- dndkit/tools/npcs.py +101 -0
- dndkit/tools/play.py +118 -0
- dndkit/tools/players.py +44 -0
- dndkit/tools/progression.py +41 -0
- dndkit/tools/quests.py +95 -0
- dndkit/tools/recorder.py +63 -0
- dndkit/tools/render.py +281 -0
- dndkit/tools/render_combat.py +121 -0
- dndkit/tools/render_map.py +141 -0
- dndkit/tools/resources.py +104 -0
- dndkit/tools/schema.py +47 -0
- dndkit/tools/screen.py +47 -0
- dndkit/tools/spells.py +67 -0
- dndkit/tools/utility.py +42 -0
- dndkit/ui/__init__.py +52 -0
- dndkit/ui/backstage.js +41 -0
- dndkit/ui/battlemap.js +70 -0
- dndkit/ui/character.html +23 -0
- dndkit/ui/combat.html +18 -0
- dndkit/ui/components.css +283 -0
- dndkit/ui/components.js +261 -0
- dndkit/ui/desk.js +91 -0
- dndkit/ui/dice.html +42 -0
- dndkit/ui/dicetray.js +99 -0
- dndkit/ui/gm.html +59 -0
- dndkit/ui/party.html +18 -0
- dndkit/ui/player.html +61 -0
- dndkit/ui/seating.html +18 -0
- dndkit/ui/shared.css +195 -0
- dndkit/ui/shared.js +242 -0
- dndkit/ui/sheet.js +75 -0
- dndkit/ui/table.html +41 -0
- dndkit/ui/words.js +120 -0
- dndkit/ui/world.js +56 -0
- dndkit/views/__init__.py +7 -0
- dndkit/views/backstage.py +49 -0
- dndkit/views/battle.py +92 -0
- dndkit/views/desk.py +76 -0
- dndkit/views/everyone.py +194 -0
- dndkit/views/public.py +89 -0
- dndkit/views/widgets.py +58 -0
- dndkit/views/world.py +70 -0
- dndkit/web/__init__.py +10 -0
- dndkit/web/access.py +136 -0
- dndkit/web/audiences.py +111 -0
- dndkit/web/changes.py +84 -0
- dndkit/web/feed.py +112 -0
- dndkit/web/routes.py +134 -0
- dndkit/web/server.py +131 -0
- dndkit-0.4.0.dist-info/METADATA +567 -0
- dndkit-0.4.0.dist-info/RECORD +118 -0
- dndkit-0.4.0.dist-info/WHEEL +5 -0
- dndkit-0.4.0.dist-info/entry_points.txt +2 -0
- dndkit-0.4.0.dist-info/licenses/LICENSE +22 -0
- dndkit-0.4.0.dist-info/top_level.txt +1 -0
dndkit/views/battle.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The battle map as the screens draw it: terrain, tokens, where the actor can go.
|
|
2
|
+
|
|
3
|
+
The game master's screens get exact hit points; the players' get how hurt a
|
|
4
|
+
foe looks, and see where a creature can move only on their own side's turns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dndkit.game import battlefield, grid, movement, pathing
|
|
8
|
+
from dndkit.models import Campaign, Combatant
|
|
9
|
+
|
|
10
|
+
Json = dict[str, object]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def health(hit_points: int | None, maximum: int | None) -> str | None:
|
|
14
|
+
"""How hurt a creature looks: unhurt, hurt, bloodied (half or less) or down."""
|
|
15
|
+
if hit_points is None or not maximum:
|
|
16
|
+
return None
|
|
17
|
+
if hit_points <= 0:
|
|
18
|
+
return "down"
|
|
19
|
+
if hit_points >= maximum:
|
|
20
|
+
return "unhurt"
|
|
21
|
+
return "bloodied" if hit_points * 2 <= maximum else "hurt"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def battle_map(campaign: Campaign, public: bool) -> Json | None:
|
|
25
|
+
"""The map for drawing, or None when the fight has none."""
|
|
26
|
+
state = campaign.game_state
|
|
27
|
+
field = state.battlefield
|
|
28
|
+
if field is None:
|
|
29
|
+
return None
|
|
30
|
+
current = state.current_combatant
|
|
31
|
+
tokens = [
|
|
32
|
+
_token(campaign, c, c is current, public)
|
|
33
|
+
for c in state.initiative_order
|
|
34
|
+
if c.position is not None and not (public and unseen(c))
|
|
35
|
+
]
|
|
36
|
+
shows_moves = current is not None and (not public or battlefield.is_party(current))
|
|
37
|
+
return {
|
|
38
|
+
"width": field.width,
|
|
39
|
+
"height": field.height,
|
|
40
|
+
"walls": [list(s) for s in sorted(battlefield.wall_squares(field))],
|
|
41
|
+
"difficult": [list(s) for s in sorted(battlefield.difficult_squares(field))],
|
|
42
|
+
"area": [list(grid.parse(s, field)) for s in field.area],
|
|
43
|
+
"area_label": field.area_label,
|
|
44
|
+
"tokens": tokens,
|
|
45
|
+
"moves": _moves(campaign, current, public) if shows_moves and current else [],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def unseen(combatant: Combatant) -> bool:
|
|
50
|
+
"""A foe hidden from the players: kept off their screens altogether."""
|
|
51
|
+
return combatant.hidden and not battlefield.is_party(combatant)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _token(campaign: Campaign, combatant: Combatant, current: bool, public: bool) -> Json:
|
|
55
|
+
x, y = battlefield.square_of(campaign.game_state, combatant) or (0, 0)
|
|
56
|
+
character = campaign.characters.get(combatant.character_id or "")
|
|
57
|
+
hp: int | None
|
|
58
|
+
hp_max: int | None
|
|
59
|
+
if character is not None:
|
|
60
|
+
hp, hp_max = character.hit_points_current, character.hp_maximum
|
|
61
|
+
else:
|
|
62
|
+
hp, hp_max = combatant.hit_points, combatant.hit_points_max
|
|
63
|
+
token: Json = {
|
|
64
|
+
"name": combatant.name,
|
|
65
|
+
"x": x,
|
|
66
|
+
"y": y,
|
|
67
|
+
"size": grid.SIZE_SQUARES[combatant.size],
|
|
68
|
+
"party": battlefield.is_party(combatant),
|
|
69
|
+
"current": current,
|
|
70
|
+
"down": hp == 0,
|
|
71
|
+
"hidden": combatant.hidden,
|
|
72
|
+
"boss": combatant.boss,
|
|
73
|
+
}
|
|
74
|
+
if public and character is None:
|
|
75
|
+
token["health"] = health(hp, hp_max)
|
|
76
|
+
else:
|
|
77
|
+
token.update(hp=hp, hp_max=hp_max)
|
|
78
|
+
return token
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _moves(campaign: Campaign, mover: Combatant, public: bool) -> list[list[int]]:
|
|
82
|
+
"""The squares *mover* can still reach this turn."""
|
|
83
|
+
start = battlefield.square_of(campaign.game_state, mover)
|
|
84
|
+
left = movement.movement_left(campaign, mover)
|
|
85
|
+
if start is None or left == 0:
|
|
86
|
+
return []
|
|
87
|
+
ground = pathing.Ground(campaign, mover, provokes=False, as_players_see=public)
|
|
88
|
+
return [
|
|
89
|
+
list(square)
|
|
90
|
+
for square, route in pathing.routes(ground, start).items()
|
|
91
|
+
if 0 < route.feet <= left and ground.standable(square)
|
|
92
|
+
]
|
dndkit/views/desk.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The game master's desk, to follow and debug the model: what it called,
|
|
2
|
+
the campaign's notebook, the full sheets, and the server's own facts."""
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from dndkit import __version__
|
|
8
|
+
from dndkit.activity import ActivityLog
|
|
9
|
+
from dndkit.models import Campaign
|
|
10
|
+
from dndkit.skills import load_catalog
|
|
11
|
+
from dndkit.views import everyone
|
|
12
|
+
from dndkit.views.everyone import Json
|
|
13
|
+
|
|
14
|
+
SHOWN_CALLS = 60
|
|
15
|
+
SHOWN_SESSIONS = 6
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def activity(log: ActivityLog) -> Json:
|
|
19
|
+
"""The model's latest calls, newest first."""
|
|
20
|
+
calls = log.recent()[-SHOWN_CALLS:]
|
|
21
|
+
return {
|
|
22
|
+
"calls": [
|
|
23
|
+
{
|
|
24
|
+
"n": c.number,
|
|
25
|
+
"time": time.strftime("%H:%M:%S", time.localtime(c.at)),
|
|
26
|
+
"kind": c.kind,
|
|
27
|
+
"name": c.name,
|
|
28
|
+
"arguments": c.arguments,
|
|
29
|
+
"outcome": c.outcome,
|
|
30
|
+
"failed": c.failed,
|
|
31
|
+
"ms": c.milliseconds,
|
|
32
|
+
}
|
|
33
|
+
for c in reversed(calls)
|
|
34
|
+
],
|
|
35
|
+
"failures": sum(c.failed for c in calls),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def notebook(campaign: Campaign, saved_in: Path | None, data_dir: Path) -> Json:
|
|
40
|
+
"""The campaign as the model's tools hold it, beyond the board: notes, the
|
|
41
|
+
setting, past sessions, and where it all lives."""
|
|
42
|
+
state = campaign.game_state
|
|
43
|
+
return {
|
|
44
|
+
"situation": state.notes,
|
|
45
|
+
"world_notes": campaign.world_notes,
|
|
46
|
+
"setting": campaign.setting,
|
|
47
|
+
"dm": campaign.dm_name,
|
|
48
|
+
"sessions": [
|
|
49
|
+
{
|
|
50
|
+
"number": s.session_number,
|
|
51
|
+
"title": s.title,
|
|
52
|
+
"summary": s.summary,
|
|
53
|
+
"moments": s.events,
|
|
54
|
+
"present": s.characters_present,
|
|
55
|
+
"xp": s.experience_gained,
|
|
56
|
+
"treasure": s.treasure_found,
|
|
57
|
+
"notes": s.notes,
|
|
58
|
+
}
|
|
59
|
+
for s in sorted(campaign.sessions, key=lambda s: s.session_number)[-SHOWN_SESSIONS:]
|
|
60
|
+
],
|
|
61
|
+
"server": {
|
|
62
|
+
"version": __version__,
|
|
63
|
+
"data_dir": str(data_dir),
|
|
64
|
+
"file": saved_in.name if saved_in else None,
|
|
65
|
+
"saved": campaign.updated_at.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
|
|
66
|
+
"skills": [s.name for s in load_catalog()],
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sheets(campaign: Campaign) -> Json:
|
|
72
|
+
"""Every character's full sheet, with the game master's notes on it."""
|
|
73
|
+
party = sorted(campaign.characters.values(), key=lambda c: c.name)
|
|
74
|
+
return {
|
|
75
|
+
"sheets": [{**everyone.character(campaign, c.id), "notes": c.notes} for c in party],
|
|
76
|
+
}
|
dndkit/views/everyone.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""What everyone at the table may see: the dice, the party, the seats, a sheet.
|
|
2
|
+
|
|
3
|
+
Each view lists the fields it sends. The MCP widgets, the table screen and the
|
|
4
|
+
players' phones all show these; the game master's views (``widgets``, ``desk``)
|
|
5
|
+
add their own on top, so nothing here may hold a secret: a foe's numbers, a
|
|
6
|
+
plan, the game master's notes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from dndkit.game import characters, proficiencies, seating
|
|
12
|
+
from dndkit.game.dice import DiceLog, LoggedRoll
|
|
13
|
+
from dndkit.game.tables import XP_FOR_LEVEL
|
|
14
|
+
from dndkit.models import ABILITIES, Campaign, Character, Item, Spell
|
|
15
|
+
from dndkit.views.battle import unseen
|
|
16
|
+
|
|
17
|
+
Json = dict[str, Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def dice(log: DiceLog, limit: int = 6, secrets: bool = False) -> Json:
|
|
21
|
+
"""The latest rolls, newest last; the secret ones only for the game master."""
|
|
22
|
+
shown = [roll for roll in log.recent() if secrets or not roll.secret]
|
|
23
|
+
return {"rolls": [_roll(roll) for roll in shown[-limit:]]}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _roll(roll: LoggedRoll) -> Json:
|
|
27
|
+
result = roll.result
|
|
28
|
+
return {
|
|
29
|
+
"n": roll.number,
|
|
30
|
+
"t": roll.at,
|
|
31
|
+
"secret": roll.secret,
|
|
32
|
+
"notation": result.notation,
|
|
33
|
+
"total": result.total,
|
|
34
|
+
"mode": result.mode,
|
|
35
|
+
"terms": [
|
|
36
|
+
{"sign": t.sign, "sides": t.sides, "faces": list(t.faces), "kept": t.kept}
|
|
37
|
+
for t in result.terms
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def party(campaign: Campaign) -> Json:
|
|
43
|
+
"""Where the party stands, every character's vitals and the quests' progress."""
|
|
44
|
+
state = campaign.game_state
|
|
45
|
+
current = state.current_combatant
|
|
46
|
+
return {
|
|
47
|
+
"campaign": campaign.name,
|
|
48
|
+
"description": campaign.description,
|
|
49
|
+
"session": state.current_session,
|
|
50
|
+
"location": state.current_location,
|
|
51
|
+
"date": state.current_date_in_game,
|
|
52
|
+
"funds": state.party_funds,
|
|
53
|
+
"in_combat": state.in_combat,
|
|
54
|
+
"round": state.combat_round,
|
|
55
|
+
# The players' pages show this too: a hidden foe's turn names no one.
|
|
56
|
+
"current": current.name if current and not unseen(current) else None,
|
|
57
|
+
"characters": [
|
|
58
|
+
{
|
|
59
|
+
"name": c.name,
|
|
60
|
+
"summary": _summary(c),
|
|
61
|
+
"player": c.player_name,
|
|
62
|
+
"present": seating.is_present(campaign, c),
|
|
63
|
+
**vitals(c),
|
|
64
|
+
}
|
|
65
|
+
for c in sorted(campaign.characters.values(), key=lambda c: c.name)
|
|
66
|
+
],
|
|
67
|
+
"quests": [
|
|
68
|
+
{"title": q.title, "done": len(q.completed_objectives), "total": len(q.objectives)}
|
|
69
|
+
for q in campaign.quests.values()
|
|
70
|
+
if q.status == "active"
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def seating_chart(campaign: Campaign) -> Json:
|
|
76
|
+
"""Who sits at the table in seat order, with their characters, and who is missing."""
|
|
77
|
+
state = campaign.game_state
|
|
78
|
+
party = sorted(campaign.characters.values(), key=lambda c: c.name)
|
|
79
|
+
return {
|
|
80
|
+
"campaign": campaign.name,
|
|
81
|
+
"session": state.current_session,
|
|
82
|
+
"called": bool(state.players_present),
|
|
83
|
+
"seats": [
|
|
84
|
+
{
|
|
85
|
+
"player": player,
|
|
86
|
+
"characters": [
|
|
87
|
+
{"name": c.name, "summary": _summary(c)}
|
|
88
|
+
for c in seating.characters_of(campaign, player)
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
for player in state.players_present
|
|
92
|
+
],
|
|
93
|
+
# Before the roll call: the players known so far.
|
|
94
|
+
"absent": [
|
|
95
|
+
{"name": c.name, "player": c.player_name}
|
|
96
|
+
for c in party
|
|
97
|
+
if c.player_name and (not state.players_present or not seating.is_present(campaign, c))
|
|
98
|
+
],
|
|
99
|
+
"unplayed": [c.name for c in party if not c.player_name],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def character(campaign: Campaign, ref: str) -> Json:
|
|
104
|
+
"""One character's sheet, as its player sees it (the game master's notes left out)."""
|
|
105
|
+
c = characters.find(campaign, ref)
|
|
106
|
+
level = c.character_class.level
|
|
107
|
+
casting = characters.spellcasting(c)
|
|
108
|
+
return {
|
|
109
|
+
"name": c.name,
|
|
110
|
+
"summary": _summary(c),
|
|
111
|
+
"player": c.player_name,
|
|
112
|
+
"background": c.background,
|
|
113
|
+
"alignment": c.alignment,
|
|
114
|
+
"description": c.description,
|
|
115
|
+
"bio": c.bio,
|
|
116
|
+
"abilities": [
|
|
117
|
+
{"key": key[:3], "score": c.abilities[key].score, "mod": c.abilities[key].mod}
|
|
118
|
+
for key in ABILITIES
|
|
119
|
+
if key in c.abilities
|
|
120
|
+
],
|
|
121
|
+
"proficiency": c.proficiency_bonus,
|
|
122
|
+
"speed": c.speed,
|
|
123
|
+
"initiative": characters.initiative(c),
|
|
124
|
+
"hit_dice": c.hit_dice_remaining,
|
|
125
|
+
"casting": (
|
|
126
|
+
{"ability": casting.ability, "attack": casting.attack, "dc": casting.save_dc}
|
|
127
|
+
if casting
|
|
128
|
+
else None
|
|
129
|
+
),
|
|
130
|
+
"xp": c.experience_points,
|
|
131
|
+
"next_level_xp": XP_FOR_LEVEL.get(level + 1),
|
|
132
|
+
"inspiration": c.inspiration,
|
|
133
|
+
"race": {"name": c.race.name, "subrace": c.race.subrace, "traits": c.race.traits},
|
|
134
|
+
"inventory": [_item(i) for i in c.inventory],
|
|
135
|
+
"equipment": [
|
|
136
|
+
{"slot": slot, **_item(item)} for slot, item in c.equipment.items() if item is not None
|
|
137
|
+
],
|
|
138
|
+
"spells": [_spell(s) for s in sorted(c.spells_known, key=lambda s: (s.level, s.name))],
|
|
139
|
+
"features": c.features_and_traits,
|
|
140
|
+
"skills": [{"name": p.name, "bonus": p.bonus} for p in proficiencies.skill_bonuses(c)],
|
|
141
|
+
"saves": [{"name": p.name[:3], "bonus": p.bonus} for p in proficiencies.save_bonuses(c)],
|
|
142
|
+
"languages": c.languages,
|
|
143
|
+
**vitals(c),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _item(i: Item) -> Json:
|
|
148
|
+
details = [i.item_type if i.item_type != "misc" else "", i.value or ""]
|
|
149
|
+
if i.weight:
|
|
150
|
+
details.append(f"{i.weight:g} lb")
|
|
151
|
+
return {
|
|
152
|
+
"name": i.name,
|
|
153
|
+
"qty": i.quantity,
|
|
154
|
+
"description": i.description,
|
|
155
|
+
"details": " · ".join(d for d in details if d),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _spell(s: Spell) -> Json:
|
|
160
|
+
components = ", ".join(s.components)
|
|
161
|
+
if s.material_components:
|
|
162
|
+
components += f" ({s.material_components})"
|
|
163
|
+
details = [s.school, s.casting_time, str(s.range) if s.range else "", s.duration, components]
|
|
164
|
+
return {
|
|
165
|
+
"name": s.name,
|
|
166
|
+
"level": s.level,
|
|
167
|
+
"prepared": s.prepared,
|
|
168
|
+
"details": " · ".join(d for d in details if d),
|
|
169
|
+
"description": s.description,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _summary(c: Character) -> str:
|
|
174
|
+
return f"{c.race.name} {c.character_class.name} {c.character_class.level}"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def vitals(c: Character) -> Json:
|
|
178
|
+
"""Hit points, defenses and resources: what the whole table may see of a character."""
|
|
179
|
+
return {
|
|
180
|
+
"hp": c.hit_points_current,
|
|
181
|
+
"hp_max": c.hp_maximum,
|
|
182
|
+
"temp": c.temporary_hit_points,
|
|
183
|
+
"ac": c.armor_class,
|
|
184
|
+
"conditions": c.conditions,
|
|
185
|
+
"rounds": c.condition_rounds,
|
|
186
|
+
"concentration": c.concentration,
|
|
187
|
+
"exhaustion": c.exhaustion,
|
|
188
|
+
"death_saves": [c.death_saves_success, c.death_saves_failure],
|
|
189
|
+
"stable": c.stable,
|
|
190
|
+
"slots": [
|
|
191
|
+
[level, maximum - c.spell_slots_used.get(level, 0), maximum]
|
|
192
|
+
for level, maximum in sorted(c.spell_slots.items())
|
|
193
|
+
],
|
|
194
|
+
}
|
dndkit/views/public.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""What the players may see: the table screen and each player's phone.
|
|
2
|
+
|
|
3
|
+
Built field by field from what is allowed, never by trimming the game
|
|
4
|
+
master's views, so a field added to a model stays hidden until it is added
|
|
5
|
+
here. Players see their party in full, but never an NPC's biography or notes,
|
|
6
|
+
a planned encounter, a monster's statistics, or a foe hidden from them: a
|
|
7
|
+
monster's hit points show as how hurt it looks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dndkit.models import Campaign, Combatant
|
|
11
|
+
from dndkit.views.battle import battle_map, health, unseen
|
|
12
|
+
from dndkit.views.everyone import Json, vitals
|
|
13
|
+
|
|
14
|
+
JOURNAL_EVENTS = 8
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def combat(campaign: Campaign) -> Json:
|
|
18
|
+
"""The initiative order; monsters without their numbers."""
|
|
19
|
+
state = campaign.game_state
|
|
20
|
+
current = state.current_combatant
|
|
21
|
+
return {
|
|
22
|
+
"in_combat": state.in_combat,
|
|
23
|
+
"round": state.combat_round,
|
|
24
|
+
"order": [
|
|
25
|
+
_fighter(campaign, c, c is current) for c in state.initiative_order if not unseen(c)
|
|
26
|
+
],
|
|
27
|
+
"defeated": [c.name for c in state.defeated if not unseen(c)],
|
|
28
|
+
"xp": sum(c.xp for c in state.defeated if not unseen(c)),
|
|
29
|
+
"map": battle_map(campaign, public=True),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fighter(campaign: Campaign, combatant: Combatant, current: bool) -> Json:
|
|
34
|
+
seen = {
|
|
35
|
+
"name": combatant.name,
|
|
36
|
+
"initiative": combatant.initiative,
|
|
37
|
+
"current": current,
|
|
38
|
+
"position": combatant.position,
|
|
39
|
+
"boss": combatant.boss,
|
|
40
|
+
}
|
|
41
|
+
character = campaign.characters.get(combatant.character_id or "")
|
|
42
|
+
if character is not None:
|
|
43
|
+
return {**seen, "pc": True, **vitals(character)}
|
|
44
|
+
return {
|
|
45
|
+
**seen,
|
|
46
|
+
"pc": False,
|
|
47
|
+
"health": health(combatant.hit_points, combatant.hit_points_max),
|
|
48
|
+
"conditions": list(combatant.conditions),
|
|
49
|
+
"rounds": dict(combatant.condition_rounds),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def journal(campaign: Campaign, limit: int = JOURNAL_EVENTS, full: bool = False) -> Json:
|
|
54
|
+
"""Active quests with their objectives, and the latest moments of the adventure log."""
|
|
55
|
+
return {
|
|
56
|
+
"quests": [
|
|
57
|
+
{
|
|
58
|
+
"title": q.title,
|
|
59
|
+
"giver": q.giver,
|
|
60
|
+
"description": q.description,
|
|
61
|
+
"objectives": [
|
|
62
|
+
{"text": text, "done": text in q.completed_objectives} for text in q.objectives
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
for q in campaign.quests.values()
|
|
66
|
+
if q.status == "active"
|
|
67
|
+
],
|
|
68
|
+
"events": [
|
|
69
|
+
{
|
|
70
|
+
"title": e.title,
|
|
71
|
+
"description": e.description,
|
|
72
|
+
"type": e.event_type.value,
|
|
73
|
+
"location": e.location,
|
|
74
|
+
# The game master's own log adds what the players do not need.
|
|
75
|
+
**(
|
|
76
|
+
{
|
|
77
|
+
"session": e.session_number,
|
|
78
|
+
"importance": e.importance,
|
|
79
|
+
"characters": e.characters_involved,
|
|
80
|
+
"tags": e.tags,
|
|
81
|
+
}
|
|
82
|
+
if full
|
|
83
|
+
else {}
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
for e in reversed(campaign.events[-limit:])
|
|
87
|
+
],
|
|
88
|
+
"total": len(campaign.events),
|
|
89
|
+
}
|
dndkit/views/widgets.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The fight as the game master sees it, in the MCP widget and the web backstage.
|
|
2
|
+
|
|
3
|
+
Monsters' exact hit points, hidden foes and legendary uses show here; what
|
|
4
|
+
the players may see is built separately, field by field, in ``views.public``
|
|
5
|
+
and ``views.everyone``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dndkit.models import Campaign, Combatant
|
|
9
|
+
from dndkit.views.battle import battle_map
|
|
10
|
+
from dndkit.views.everyone import Json, vitals
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def combat(campaign: Campaign) -> Json:
|
|
14
|
+
"""The initiative order with live hit points, and who has fallen."""
|
|
15
|
+
state = campaign.game_state
|
|
16
|
+
current = state.current_combatant
|
|
17
|
+
return {
|
|
18
|
+
"in_combat": state.in_combat,
|
|
19
|
+
"round": state.combat_round,
|
|
20
|
+
"order": [_combatant(campaign, c, c is current) for c in state.initiative_order],
|
|
21
|
+
"defeated": [c.name for c in state.defeated],
|
|
22
|
+
"xp": sum(c.xp for c in state.defeated),
|
|
23
|
+
"map": battle_map(campaign, public=False),
|
|
24
|
+
"last": (
|
|
25
|
+
state.last_combat.model_dump(mode="json")
|
|
26
|
+
if state.last_combat and not state.in_combat
|
|
27
|
+
else None
|
|
28
|
+
),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _combatant(campaign: Campaign, combatant: Combatant, current: bool) -> Json:
|
|
33
|
+
character = campaign.characters.get(combatant.character_id or "")
|
|
34
|
+
if character is not None:
|
|
35
|
+
stats = vitals(character)
|
|
36
|
+
else:
|
|
37
|
+
stats = {
|
|
38
|
+
"hp": combatant.hit_points,
|
|
39
|
+
"hp_max": combatant.hit_points_max,
|
|
40
|
+
"temp": 0,
|
|
41
|
+
"ac": combatant.armor_class,
|
|
42
|
+
"conditions": combatant.conditions,
|
|
43
|
+
"rounds": combatant.condition_rounds,
|
|
44
|
+
"concentration": combatant.concentration,
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
"name": combatant.name,
|
|
48
|
+
"initiative": combatant.initiative,
|
|
49
|
+
"pc": character is not None,
|
|
50
|
+
"current": current,
|
|
51
|
+
"position": combatant.position,
|
|
52
|
+
"hidden": combatant.hidden,
|
|
53
|
+
"reaction_used": combatant.reaction_used,
|
|
54
|
+
"boss": combatant.boss,
|
|
55
|
+
"legendary": [combatant.legendary_actions_left, combatant.legendary_actions],
|
|
56
|
+
"resistances": combatant.legendary_resistances_left,
|
|
57
|
+
**stats,
|
|
58
|
+
}
|
dndkit/views/world.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The world as the game master knows it: NPCs, places and the bestiary, secrets included."""
|
|
2
|
+
|
|
3
|
+
from dndkit.models import NPC, Campaign, Location, Monster
|
|
4
|
+
from dndkit.text import normalize
|
|
5
|
+
from dndkit.views.everyone import Json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def world(campaign: Campaign) -> Json:
|
|
9
|
+
"""Every NPC, location and stat block, by name."""
|
|
10
|
+
here = normalize(campaign.game_state.current_location or "")
|
|
11
|
+
fighting = [c.monster for c in campaign.game_state.initiative_order if c.monster]
|
|
12
|
+
return {
|
|
13
|
+
"npcs": [_npc(n) for n in sorted(campaign.npcs.values(), key=lambda n: n.name)],
|
|
14
|
+
"locations": [
|
|
15
|
+
_location(place, normalize(place.name) == here)
|
|
16
|
+
for place in sorted(campaign.locations.values(), key=lambda p: p.name)
|
|
17
|
+
],
|
|
18
|
+
"monsters": [
|
|
19
|
+
_monster(m, fighting.count(m.name))
|
|
20
|
+
for m in sorted(campaign.monsters.values(), key=lambda m: m.name)
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _npc(npc: NPC) -> Json:
|
|
26
|
+
return {
|
|
27
|
+
"name": npc.name,
|
|
28
|
+
"race": npc.race,
|
|
29
|
+
"occupation": npc.occupation,
|
|
30
|
+
"location": npc.location,
|
|
31
|
+
"attitude": npc.attitude,
|
|
32
|
+
"description": npc.description,
|
|
33
|
+
"bio": npc.bio,
|
|
34
|
+
"notes": npc.notes,
|
|
35
|
+
"ties": [f"{who}: {what}" for who, what in npc.relationships.items()],
|
|
36
|
+
"stats": [f"{key}: {value}" for key, value in (npc.stats or {}).items()],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _location(place: Location, current: bool) -> Json:
|
|
41
|
+
return {
|
|
42
|
+
"name": place.name,
|
|
43
|
+
"type": place.location_type,
|
|
44
|
+
"current": current,
|
|
45
|
+
"description": place.description,
|
|
46
|
+
"population": place.population,
|
|
47
|
+
"government": place.government,
|
|
48
|
+
"features": place.notable_features,
|
|
49
|
+
"npcs": place.npcs,
|
|
50
|
+
"connections": place.connections,
|
|
51
|
+
"notes": place.notes,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _monster(monster: Monster, fighting: int) -> Json:
|
|
56
|
+
return {
|
|
57
|
+
"name": monster.name,
|
|
58
|
+
"size": monster.size,
|
|
59
|
+
"ac": monster.armor_class,
|
|
60
|
+
"hp": monster.hit_points,
|
|
61
|
+
"speed": monster.speed,
|
|
62
|
+
"cr": monster.challenge_rating,
|
|
63
|
+
"xp": monster.xp,
|
|
64
|
+
"initiative": monster.initiative_bonus,
|
|
65
|
+
"boss": monster.boss,
|
|
66
|
+
"legendary": [monster.legendary_actions, monster.legendary_resistances],
|
|
67
|
+
"actions": monster.actions,
|
|
68
|
+
"notes": monster.notes,
|
|
69
|
+
"fighting": fighting,
|
|
70
|
+
}
|
dndkit/web/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Web pages that follow the game live: the table screen, players' phones, the backstage.
|
|
2
|
+
|
|
3
|
+
They run inside the MCP server's process (``server.web_pages``) and read the
|
|
4
|
+
same campaign store and dice log as the tools, so a change shows at once.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dndkit.web.changes import Changes
|
|
8
|
+
from dndkit.web.server import web_pages
|
|
9
|
+
|
|
10
|
+
__all__ = ["Changes", "web_pages"]
|