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/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""dndkit: a D&D 5e campaign server for voice and chat game masters."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("dndkit")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
dndkit/__main__.py
ADDED
dndkit/activity.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""What the model asks of the server, kept for the game master's backstage.
|
|
2
|
+
|
|
3
|
+
Every tool call is recorded with its arguments, its answer or its error and
|
|
4
|
+
how long it took, and so are the skills the client fetches: enough to follow
|
|
5
|
+
what the model does, and to see why something did not happen. The log lives
|
|
6
|
+
in memory for the session, like the dice log; nothing is saved. Calls a client
|
|
7
|
+
refuses before sending them never reach the server, so they are not here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import itertools
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections import deque
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
TEXT_LIMIT = 1200 # characters kept of arguments and answers
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Call:
|
|
22
|
+
"""One request from the model."""
|
|
23
|
+
|
|
24
|
+
number: int
|
|
25
|
+
at: float # unix time
|
|
26
|
+
kind: str # "tool" or "skill"
|
|
27
|
+
name: str
|
|
28
|
+
arguments: str
|
|
29
|
+
outcome: str
|
|
30
|
+
failed: bool
|
|
31
|
+
milliseconds: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ActivityLog:
|
|
35
|
+
"""The latest calls, safe from FastMCP's worker threads."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, size: int = 200) -> None:
|
|
38
|
+
self._calls: deque[Call] = deque(maxlen=size)
|
|
39
|
+
self._counter = itertools.count(1)
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
self.on_record: Callable[[], None] = lambda: None # set by the server's lifespan
|
|
42
|
+
|
|
43
|
+
def record(
|
|
44
|
+
self, kind: str, name: str, arguments: str, outcome: str, failed: bool, started: float
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Keep one call; *started* is its ``time.perf_counter()`` start."""
|
|
47
|
+
call = Call(
|
|
48
|
+
number=next(self._counter),
|
|
49
|
+
at=time.time(),
|
|
50
|
+
kind=kind,
|
|
51
|
+
name=name,
|
|
52
|
+
arguments=_cut(arguments),
|
|
53
|
+
outcome=_cut(outcome),
|
|
54
|
+
failed=failed,
|
|
55
|
+
milliseconds=round((time.perf_counter() - started) * 1000),
|
|
56
|
+
)
|
|
57
|
+
with self._lock:
|
|
58
|
+
self._calls.append(call)
|
|
59
|
+
self.on_record()
|
|
60
|
+
|
|
61
|
+
def recent(self) -> list[Call]:
|
|
62
|
+
"""Oldest first."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
return list(self._calls)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cut(text: str) -> str:
|
|
68
|
+
return text if len(text) <= TEXT_LIMIT else text[: TEXT_LIMIT - 1] + "…"
|
dndkit/app.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Server assembly and the command-line entry point.
|
|
2
|
+
|
|
3
|
+
Nothing touches the disk at import: the campaign store opens in the server's
|
|
4
|
+
lifespan, so importing the package (tests, ``fastmcp inspect``) has no side
|
|
5
|
+
effect, and a bad data directory fails the start with one clear log line.
|
|
6
|
+
The web pages, when configured, run in the same lifespan.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fastmcp import FastMCP
|
|
16
|
+
|
|
17
|
+
from dndkit import __version__
|
|
18
|
+
from dndkit.activity import ActivityLog
|
|
19
|
+
from dndkit.config import (
|
|
20
|
+
WebSettings,
|
|
21
|
+
configure_logging,
|
|
22
|
+
resolve_data_dir,
|
|
23
|
+
resolve_web_settings,
|
|
24
|
+
)
|
|
25
|
+
from dndkit.game.dice import DiceLog
|
|
26
|
+
from dndkit.skills import register_skills
|
|
27
|
+
from dndkit.storage import CampaignStore
|
|
28
|
+
from dndkit.tools import ALL_TOOLS
|
|
29
|
+
from dndkit.tools.apps import register_apps
|
|
30
|
+
from dndkit.tools.deps import DICE_LOG_KEY, STORE_KEY
|
|
31
|
+
from dndkit.tools.recorder import Recorder
|
|
32
|
+
from dndkit.tools.schema import compact
|
|
33
|
+
from dndkit.web import Changes, web_pages
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
INSTRUCTIONS = """\
|
|
38
|
+
Campaign manager for a Dungeons & Dragons 5e game master, built for voice play.
|
|
39
|
+
You are the game master: you lead, and act with the tools without asking leave.
|
|
40
|
+
Read skill://dnd-game-master/SKILL.md first: the tools, a session's course, and
|
|
41
|
+
preparing a campaign by the rules; check_campaign then lists what is missing.
|
|
42
|
+
One campaign is active at a time; list_campaigns and load_campaign switch it.
|
|
43
|
+
Each spoken turn opens with the speaker's name: a player. seat_players says
|
|
44
|
+
who plays which character; "I attack" from a player is their character's
|
|
45
|
+
action. At the start of a session, ask who is at the table and call
|
|
46
|
+
seat_players with start_over; call it again when someone arrives or leaves.
|
|
47
|
+
Characters, NPCs, locations and quests are found by name (case and accents do
|
|
48
|
+
not matter) or by ID. A failed call says why and lists the known names: retry
|
|
49
|
+
with one of them rather than inventing a new entity. Use change_hit_points for
|
|
50
|
+
damage and healing (characters and monsters alike), roll_dice for every roll,
|
|
51
|
+
and log notable moments with add_event. Resolve every roll yourself: the attack
|
|
52
|
+
against the target's AC, then the damage, applied with change_hit_points; never
|
|
53
|
+
ask the players for a total or a number the tools hold (get_character gives a
|
|
54
|
+
character's spell attack and save DC). You run the monsters: one the story
|
|
55
|
+
needs and the bestiary lacks is yours to create with save_monster (a challenge
|
|
56
|
+
rating sets its stats; mark the boss), then plan_encounter rates the fight.
|
|
57
|
+
How-to skills, which also bring their tools when your client hides them:
|
|
58
|
+
skill://dnd-monsters/SKILL.md (save_monster, plan_encounter: monsters, bosses,
|
|
59
|
+
sizing a fight) and skill://dnd-battle-map/SKILL.md (the map tools)."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_server(data_dir: Path | None = None, web: WebSettings | None = None) -> FastMCP:
|
|
63
|
+
"""The MCP server; campaigns live in *data_dir*, else the configured directory.
|
|
64
|
+
|
|
65
|
+
With *web*, the server also serves the live web pages (``web/``).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
activity = ActivityLog() # in memory, filled by the Recorder middleware
|
|
69
|
+
|
|
70
|
+
@asynccontextmanager
|
|
71
|
+
async def lifespan(_server: FastMCP) -> AsyncIterator[dict[str, Any]]:
|
|
72
|
+
directory = data_dir or resolve_data_dir()
|
|
73
|
+
logger.info("dndkit %s, data in %s", __version__, directory)
|
|
74
|
+
changes = Changes()
|
|
75
|
+
store = CampaignStore(directory, on_change=changes.notify)
|
|
76
|
+
dice_log = DiceLog(on_record=changes.notify)
|
|
77
|
+
activity.on_record = changes.notify_activity
|
|
78
|
+
try:
|
|
79
|
+
async with web_pages(web, store, dice_log, activity, changes):
|
|
80
|
+
yield {STORE_KEY: store, DICE_LOG_KEY: dice_log}
|
|
81
|
+
finally:
|
|
82
|
+
store.close()
|
|
83
|
+
|
|
84
|
+
server = FastMCP(
|
|
85
|
+
name="dndkit",
|
|
86
|
+
instructions=INSTRUCTIONS,
|
|
87
|
+
version=__version__,
|
|
88
|
+
lifespan=lifespan,
|
|
89
|
+
# Unexpected exceptions reach the client as a bare failure; game
|
|
90
|
+
# errors (``GameError``) keep their message, written for the model.
|
|
91
|
+
mask_error_details=True,
|
|
92
|
+
middleware=[Recorder(activity)],
|
|
93
|
+
)
|
|
94
|
+
for fn in ALL_TOOLS:
|
|
95
|
+
tool = server.add_tool(fn)
|
|
96
|
+
tool.parameters = compact(tool.parameters)
|
|
97
|
+
register_apps(server)
|
|
98
|
+
register_skills(server, activity)
|
|
99
|
+
return server
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main() -> None:
|
|
103
|
+
"""Run the server over stdio, the transport MCP clients launch."""
|
|
104
|
+
configure_logging()
|
|
105
|
+
build_server(web=resolve_web_settings()).run(show_banner=False)
|
dndkit/config.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Runtime configuration: where the data lives, how much is logged, the web pages.
|
|
2
|
+
|
|
3
|
+
MCP clients such as roomkit-ui or Claude Desktop start the server with no
|
|
4
|
+
working directory of their own (often ``/`` on macOS), so the data directory
|
|
5
|
+
never depends on the current directory: it comes from the environment, else
|
|
6
|
+
the platform's per-user data directory.
|
|
7
|
+
|
|
8
|
+
The project was called gamemaster-mcp before its rename: its ``GAMEMASTER_*``
|
|
9
|
+
variables and its data directory still work.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from platformdirs import user_data_path
|
|
20
|
+
|
|
21
|
+
APP_NAME = "dndkit"
|
|
22
|
+
LEGACY_NAME = "gamemaster-mcp"
|
|
23
|
+
ENV_PREFIX, LEGACY_ENV_PREFIX = "DNDKIT_", "GAMEMASTER_"
|
|
24
|
+
ENV_DATA_DIR = "DNDKIT_STORAGE_DIR"
|
|
25
|
+
ENV_LOG_LEVEL = "DNDKIT_LOG_LEVEL"
|
|
26
|
+
ENV_WEB_PORT = "DNDKIT_WEB_PORT"
|
|
27
|
+
ENV_WEB_HOST = "DNDKIT_WEB_HOST"
|
|
28
|
+
ENV_WEB_URL = "DNDKIT_WEB_URL"
|
|
29
|
+
DEFAULT_WEB_HOST = "127.0.0.1"
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class WebSettings:
|
|
36
|
+
"""Where the web pages listen. Port 0 picks a free one (tests)."""
|
|
37
|
+
|
|
38
|
+
port: int
|
|
39
|
+
host: str = DEFAULT_WEB_HOST
|
|
40
|
+
public_url: str | None = None # how phones reach the pages when the server cannot tell
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def setting(env: Mapping[str, str], name: str) -> str:
|
|
44
|
+
"""``$name``, else the same variable under its name from before the rename."""
|
|
45
|
+
value = env.get(name, "").strip()
|
|
46
|
+
if not value and name.startswith(ENV_PREFIX):
|
|
47
|
+
value = env.get(LEGACY_ENV_PREFIX + name.removeprefix(ENV_PREFIX), "").strip()
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_data_dir(env: Mapping[str, str] = os.environ) -> Path:
|
|
52
|
+
"""The data directory: ``$DNDKIT_STORAGE_DIR``, else the per-user data dir.
|
|
53
|
+
|
|
54
|
+
A relative path would depend on the client's working directory: it is
|
|
55
|
+
refused, and the per-user data dir used instead.
|
|
56
|
+
"""
|
|
57
|
+
configured = setting(env, ENV_DATA_DIR)
|
|
58
|
+
if configured:
|
|
59
|
+
path = Path(configured).expanduser()
|
|
60
|
+
if path.is_absolute():
|
|
61
|
+
return path.resolve()
|
|
62
|
+
logger.warning("Ignoring %s=%r: give an absolute path.", ENV_DATA_DIR, configured)
|
|
63
|
+
return per_user_data_dir(
|
|
64
|
+
user_data_path(APP_NAME, appauthor=False), user_data_path(LEGACY_NAME, appauthor=False)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def per_user_data_dir(current: Path, legacy: Path) -> Path:
|
|
69
|
+
"""*current*, unless only the data directory from before the rename exists."""
|
|
70
|
+
if not current.exists() and legacy.is_dir():
|
|
71
|
+
return legacy
|
|
72
|
+
return current
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def configure_logging(env: Mapping[str, str] = os.environ) -> None:
|
|
76
|
+
"""Log to stderr: on the stdio transport, stdout carries the MCP protocol."""
|
|
77
|
+
name = (setting(env, ENV_LOG_LEVEL) or "INFO").upper()
|
|
78
|
+
# A misspelt level falls back to INFO: a typo must not stop the server.
|
|
79
|
+
level = logging.getLevelNamesMapping().get(name, logging.INFO)
|
|
80
|
+
logging.basicConfig(
|
|
81
|
+
level=level,
|
|
82
|
+
stream=sys.stderr,
|
|
83
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_web_settings(env: Mapping[str, str] = os.environ) -> WebSettings | None:
|
|
88
|
+
"""The web pages' settings; None (no web pages) unless ``$DNDKIT_WEB_PORT`` is set."""
|
|
89
|
+
raw_port = setting(env, ENV_WEB_PORT)
|
|
90
|
+
if not raw_port:
|
|
91
|
+
return None
|
|
92
|
+
if not (raw_port.isascii() and raw_port.isdigit()) or int(raw_port) > 65535:
|
|
93
|
+
# The game must still run: only the web pages are lost to a typo.
|
|
94
|
+
logger.warning("Web pages are off: %s=%r is not a port number.", ENV_WEB_PORT, raw_port)
|
|
95
|
+
return None
|
|
96
|
+
public_url = setting(env, ENV_WEB_URL).rstrip("/") or None
|
|
97
|
+
if public_url and not public_url.startswith(("http://", "https://")):
|
|
98
|
+
logger.warning("Ignoring %s=%r: not an http(s) address.", ENV_WEB_URL, public_url)
|
|
99
|
+
public_url = None
|
|
100
|
+
return WebSettings(
|
|
101
|
+
port=int(raw_port),
|
|
102
|
+
host=setting(env, ENV_WEB_HOST) or DEFAULT_WEB_HOST,
|
|
103
|
+
public_url=public_url,
|
|
104
|
+
)
|
dndkit/errors.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Errors the game raises when a request cannot be carried out.
|
|
2
|
+
|
|
3
|
+
They subclass FastMCP's ``ToolError`` on purpose: FastMCP sends a ``ToolError``
|
|
4
|
+
message to the client verbatim as an ``is_error`` result, while any other
|
|
5
|
+
exception is masked. The model therefore reads "No character named 'Arya'.
|
|
6
|
+
Known characters: Aria." and can correct itself, instead of a bare failure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from fastmcp.exceptions import ToolError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GameError(ToolError):
|
|
15
|
+
"""A request the game cannot carry out; the message is written for the model."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str) -> None:
|
|
18
|
+
# An expected game outcome, not a server fault: keep it out of error logs.
|
|
19
|
+
super().__init__(message, log_level=logging.INFO)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NotFoundError(GameError):
|
|
23
|
+
"""No entity answers to the given name or ID."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AlreadyExistsError(GameError):
|
|
27
|
+
"""The name is already taken by another entity of the same kind."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InvalidChangeError(GameError):
|
|
31
|
+
"""The change would leave the game in an invalid state."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DiceError(GameError):
|
|
35
|
+
"""The dice notation cannot be parsed or is out of bounds."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
NO_ACTIVE_CAMPAIGN = (
|
|
39
|
+
"No campaign is active. Create one with create_campaign "
|
|
40
|
+
"or open one with load_campaign (list_campaigns shows them)."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NoActiveCampaignError(GameError):
|
|
45
|
+
"""Every game tool needs an active campaign."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, message: str = NO_ACTIVE_CAMPAIGN) -> None:
|
|
48
|
+
super().__init__(message)
|
dndkit/game/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Game logic over a Campaign object: no MCP, no disk, testable on its own."""
|
dndkit/game/areas.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Areas of effect on the battle map (PHB ch. 10, DMG ch. 8 "Areas of Effect").
|
|
2
|
+
|
|
3
|
+
A sphere (a cylinder, seen from above) of radius R holds every square within
|
|
4
|
+
R feet of its centre square, measured as movement is. A cube of side S is
|
|
5
|
+
S/5 squares a side: centred on a square or a creature, or against its
|
|
6
|
+
caster's side facing the target. Cones and lines start at the edge of their
|
|
7
|
+
caster's space; a square is in when at least half of it is (DMG), and a cone
|
|
8
|
+
is as wide as it is far. Walls stop an area: a square is out when the line
|
|
9
|
+
from the area's origin to its centre crosses a wall. A creature is caught
|
|
10
|
+
when any square it holds is in.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from dndkit.errors import InvalidChangeError
|
|
19
|
+
from dndkit.game import battlefield, combat, grid, sight
|
|
20
|
+
from dndkit.game.grid import Square
|
|
21
|
+
from dndkit.game.sight import Point
|
|
22
|
+
from dndkit.models import Combatant, GameState
|
|
23
|
+
|
|
24
|
+
Shape = Literal["sphere", "cube", "cone", "line"]
|
|
25
|
+
SAMPLES = 4 # per side of a square, to measure how much of it an area covers
|
|
26
|
+
_SQUARE = re.compile(r"\s*[A-Za-z]\s*\d{1,2}\s*")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Spot:
|
|
31
|
+
"""Where an area is anchored: some squares, and the creature on them if any."""
|
|
32
|
+
|
|
33
|
+
squares: list[Square]
|
|
34
|
+
creature: Combatant | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Area:
|
|
39
|
+
"""What an area of effect covers and who it catches."""
|
|
40
|
+
|
|
41
|
+
label: str
|
|
42
|
+
squares: list[Square]
|
|
43
|
+
caught: list[Combatant]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Aim:
|
|
48
|
+
"""How an area is aimed, as a tool call describes it."""
|
|
49
|
+
|
|
50
|
+
shape: Shape
|
|
51
|
+
feet: int # radius (sphere), side (cube), length (cone, line)
|
|
52
|
+
at: str | None = None # centre: a square or a creature
|
|
53
|
+
source: str | None = None # the caster, for a cone, a line or a cube against them
|
|
54
|
+
toward: str | None = None # where a cone, line or cube points
|
|
55
|
+
width: int = grid.FEET # a line's width
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve(state: GameState, aim: Aim) -> Area:
|
|
59
|
+
"""The area *aim* covers, drawn on the map until the next turn."""
|
|
60
|
+
field = battlefield.require(state)
|
|
61
|
+
walls = battlefield.wall_squares(field)
|
|
62
|
+
if aim.shape in ("cone", "line") or (aim.shape == "cube" and aim.source):
|
|
63
|
+
source, toward = _need(state, aim.source, "source"), _need(state, aim.toward, "toward")
|
|
64
|
+
origin, squares = _from_caster(state, aim, source, toward)
|
|
65
|
+
exclude = source
|
|
66
|
+
else:
|
|
67
|
+
centre = _need(state, aim.at, "at")
|
|
68
|
+
origin = sight.centre(centre.squares)
|
|
69
|
+
squares = (
|
|
70
|
+
_sphere(state, centre, aim.feet) if aim.shape == "sphere" else _cube(centre, aim.feet)
|
|
71
|
+
)
|
|
72
|
+
exclude = Spot([], None)
|
|
73
|
+
inside = [
|
|
74
|
+
s
|
|
75
|
+
for s in squares
|
|
76
|
+
if grid.inside(s, field)
|
|
77
|
+
and s not in walls
|
|
78
|
+
and s not in exclude.squares
|
|
79
|
+
and not sight.blocked(origin, sight.centre([s]), walls)
|
|
80
|
+
]
|
|
81
|
+
held = set(inside)
|
|
82
|
+
caught = [
|
|
83
|
+
c
|
|
84
|
+
for c, squares_held in _everyone(state)
|
|
85
|
+
if c is not exclude.creature and held & set(squares_held)
|
|
86
|
+
]
|
|
87
|
+
label = f"{aim.feet}-ft {aim.shape}"
|
|
88
|
+
field.area, field.area_label = [grid.name(s) for s in inside], label
|
|
89
|
+
return Area(label, inside, caught)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _sphere(state: GameState, centre: Spot, feet: int) -> list[Square]:
|
|
93
|
+
field = battlefield.require(state)
|
|
94
|
+
every = [(x, y) for y in range(field.height) for x in range(field.width)]
|
|
95
|
+
return [s for s in every if grid.feet_between([s], centre.squares) <= feet]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cube(centre: Spot, feet: int) -> list[Square]:
|
|
99
|
+
side = max(1, feet // grid.FEET)
|
|
100
|
+
xs = [x for x, _ in centre.squares]
|
|
101
|
+
ys = [y for _, y in centre.squares]
|
|
102
|
+
return [
|
|
103
|
+
(x, y) for y in _span(min(ys), max(ys), 0, side) for x in _span(min(xs), max(xs), 0, side)
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _from_caster(
|
|
108
|
+
state: GameState, aim: Aim, source: Spot, toward: Spot
|
|
109
|
+
) -> tuple[Point, list[Square]]:
|
|
110
|
+
start, end = sight.centre(source.squares), sight.centre(toward.squares)
|
|
111
|
+
dx, dy = end[0] - start[0], end[1] - start[1]
|
|
112
|
+
if not (dx or dy):
|
|
113
|
+
raise InvalidChangeError("The area points nowhere: aim it at another square.")
|
|
114
|
+
if aim.shape == "cube":
|
|
115
|
+
return start, _cube_against(source, dx, dy, aim.feet)
|
|
116
|
+
length = math.hypot(dx, dy)
|
|
117
|
+
ux, uy = dx / length, dy / length
|
|
118
|
+
# The origin is where the line from the caster's centre leaves its space.
|
|
119
|
+
half = (max(x for x, _ in source.squares) - min(x for x, _ in source.squares) + 1) * 2.5
|
|
120
|
+
edge = half / max(abs(ux), abs(uy))
|
|
121
|
+
origin = (start[0] + ux * edge, start[1] + uy * edge)
|
|
122
|
+
field = battlefield.require(state)
|
|
123
|
+
every = [(x, y) for y in range(field.height) for x in range(field.width)]
|
|
124
|
+
covered = [s for s in every if _share(s, origin, (ux, uy), aim) >= 0.5]
|
|
125
|
+
return origin, covered
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _cube_against(source: Spot, dx: float, dy: float, feet: int) -> list[Square]:
|
|
129
|
+
side = max(1, feet // grid.FEET)
|
|
130
|
+
xs = [x for x, _ in source.squares]
|
|
131
|
+
ys = [y for _, y in source.squares]
|
|
132
|
+
return [
|
|
133
|
+
(x, y)
|
|
134
|
+
for y in _span(min(ys), max(ys), dy, side)
|
|
135
|
+
for x in _span(min(xs), max(xs), dx, side)
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _span(low: int, high: int, step: float, side: int) -> range:
|
|
140
|
+
"""*side* rows or columns: past *high* (step > 0), before *low* (< 0), or centred."""
|
|
141
|
+
if step > 0:
|
|
142
|
+
return range(high + 1, high + 1 + side)
|
|
143
|
+
if step < 0:
|
|
144
|
+
return range(low - side, low)
|
|
145
|
+
first = low + (high - low + 1 - side) // 2
|
|
146
|
+
return range(first, first + side)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _share(square: Square, origin: Point, direction: Point, aim: Aim) -> float:
|
|
150
|
+
"""How much of *square* the cone or line covers, from 0 to 1."""
|
|
151
|
+
inside = 0
|
|
152
|
+
for i in range(SAMPLES):
|
|
153
|
+
for j in range(SAMPLES):
|
|
154
|
+
px = (square[0] + (i + 0.5) / SAMPLES) * grid.FEET - origin[0]
|
|
155
|
+
py = (square[1] + (j + 0.5) / SAMPLES) * grid.FEET - origin[1]
|
|
156
|
+
along = px * direction[0] + py * direction[1]
|
|
157
|
+
across = abs(px * direction[1] - py * direction[0])
|
|
158
|
+
limit = along / 2 if aim.shape == "cone" else aim.width / 2
|
|
159
|
+
inside += 0 < along <= aim.feet and across <= limit
|
|
160
|
+
return inside / SAMPLES**2
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _need(state: GameState, text: str | None, role: str) -> Spot:
|
|
164
|
+
if not text:
|
|
165
|
+
raise InvalidChangeError(f"This area needs {role}: a square like E5, or a creature.")
|
|
166
|
+
if _SQUARE.fullmatch(text):
|
|
167
|
+
return Spot([grid.parse(text, battlefield.require(state))], None)
|
|
168
|
+
creature = combat.find(state, text)
|
|
169
|
+
held = battlefield.footprint_of(state, creature)
|
|
170
|
+
if held is None:
|
|
171
|
+
raise InvalidChangeError(f"{creature.name} is not on the map.")
|
|
172
|
+
return Spot(held, creature)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _everyone(state: GameState) -> list[tuple[Combatant, list[Square]]]:
|
|
176
|
+
placed = []
|
|
177
|
+
for combatant in state.initiative_order:
|
|
178
|
+
held = battlefield.footprint_of(state, combatant)
|
|
179
|
+
if held is not None:
|
|
180
|
+
placed.append((combatant, held))
|
|
181
|
+
return placed
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The battle map of the running fight: its terrain, and who stands where.
|
|
2
|
+
|
|
3
|
+
Combatants keep their own square (``Combatant.position``, the top-left one
|
|
4
|
+
for a creature bigger than a square). Whoever has none when the map is laid
|
|
5
|
+
out or the fight starts is placed at an edge: the party on the left, its
|
|
6
|
+
foes on the right.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
|
|
11
|
+
from dndkit.errors import InvalidChangeError
|
|
12
|
+
from dndkit.game import grid
|
|
13
|
+
from dndkit.game.grid import Square
|
|
14
|
+
from dndkit.models import Battlefield, Combatant, GameState
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def require(state: GameState) -> Battlefield:
|
|
18
|
+
"""The map, or an error saying how to lay one out."""
|
|
19
|
+
if state.battlefield is None:
|
|
20
|
+
raise InvalidChangeError(
|
|
21
|
+
"There is no battle map: lay one out with set_battlefield, "
|
|
22
|
+
"or keep describing positions in words."
|
|
23
|
+
)
|
|
24
|
+
return state.battlefield
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build(width: int, height: int, walls: Iterable[str], difficult: Iterable[str]) -> Battlefield:
|
|
28
|
+
"""A map with its terrain squares checked and listed one by one."""
|
|
29
|
+
field = Battlefield(width=width, height=height)
|
|
30
|
+
blocked = _names(field, walls)
|
|
31
|
+
field.walls = blocked
|
|
32
|
+
field.difficult = [s for s in _names(field, difficult) if s not in blocked]
|
|
33
|
+
return field
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def lay_out(
|
|
37
|
+
state: GameState, width: int, height: int, walls: Iterable[str], difficult: Iterable[str]
|
|
38
|
+
) -> list[Combatant]:
|
|
39
|
+
"""Replace the map; returns those who lost their square (off the map, in a wall)."""
|
|
40
|
+
state.battlefield = build(width, height, walls, difficult)
|
|
41
|
+
return settle(state)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def settle(state: GameState) -> list[Combatant]:
|
|
45
|
+
"""Take off the map whoever stands off it, in a wall or on someone placed before."""
|
|
46
|
+
field = require(state)
|
|
47
|
+
walls = wall_squares(field)
|
|
48
|
+
taken: set[Square] = set()
|
|
49
|
+
displaced = []
|
|
50
|
+
for combatant in state.initiative_order:
|
|
51
|
+
if combatant.position is None:
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
held = set(footprint_of(state, combatant) or [])
|
|
55
|
+
except InvalidChangeError: # off the map
|
|
56
|
+
held = set()
|
|
57
|
+
if not held or held & (walls | taken) or any(not grid.inside(s, field) for s in held):
|
|
58
|
+
combatant.position = None
|
|
59
|
+
displaced.append(combatant)
|
|
60
|
+
continue
|
|
61
|
+
combatant.position = grid.name(min(held, key=lambda s: (s[1], s[0]))) # "d 7" -> "D7"
|
|
62
|
+
taken |= held
|
|
63
|
+
return displaced
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_party(combatant: Combatant) -> bool:
|
|
67
|
+
"""Whether *combatant* fights on the party's side."""
|
|
68
|
+
return combatant.character_id is not None or combatant.ally
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def square_of(state: GameState, combatant: Combatant) -> Square | None:
|
|
72
|
+
"""The top-left square *combatant* stands on, if it has one on the map."""
|
|
73
|
+
if combatant.position is None or state.battlefield is None:
|
|
74
|
+
return None
|
|
75
|
+
return grid.parse(combatant.position, state.battlefield)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def footprint_of(state: GameState, combatant: Combatant) -> list[Square] | None:
|
|
79
|
+
"""The squares *combatant* holds, if it is on the map."""
|
|
80
|
+
top_left = square_of(state, combatant)
|
|
81
|
+
return None if top_left is None else grid.footprint(top_left, combatant.size)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def free(state: GameState, mover: Combatant, top_left: Square) -> bool:
|
|
85
|
+
"""Whether *mover* can stand at *top_left*: on the map, off walls, alone."""
|
|
86
|
+
field = require(state)
|
|
87
|
+
squares = grid.footprint(top_left, mover.size)
|
|
88
|
+
walls = wall_squares(field)
|
|
89
|
+
if any(not grid.inside(s, field) or s in walls for s in squares):
|
|
90
|
+
return False
|
|
91
|
+
return not any(set(squares) & set(held) for _, held in others(state, mover))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def others(state: GameState, mover: Combatant) -> list[tuple[Combatant, list[Square]]]:
|
|
95
|
+
"""Every other combatant on the map, with the squares it holds."""
|
|
96
|
+
placed = []
|
|
97
|
+
for combatant in state.initiative_order:
|
|
98
|
+
held = None if combatant is mover else footprint_of(state, combatant)
|
|
99
|
+
if held is not None:
|
|
100
|
+
placed.append((combatant, held))
|
|
101
|
+
return placed
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def place(state: GameState, combatant: Combatant, square: str) -> None:
|
|
105
|
+
"""Put *combatant* on *square* (its top-left one), which must be free."""
|
|
106
|
+
top_left = grid.parse(square, require(state))
|
|
107
|
+
if not free(state, combatant, top_left):
|
|
108
|
+
raise InvalidChangeError(
|
|
109
|
+
f"{combatant.name} cannot stand on {grid.name(top_left)}: "
|
|
110
|
+
"a wall, the map's edge or someone else is in the way."
|
|
111
|
+
)
|
|
112
|
+
combatant.position = grid.name(top_left)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def place_the_rest(state: GameState) -> list[Combatant]:
|
|
116
|
+
"""Place everyone still off the map at their side's edge; returns who was placed."""
|
|
117
|
+
if state.battlefield is None:
|
|
118
|
+
return []
|
|
119
|
+
placed = []
|
|
120
|
+
for combatant in state.initiative_order:
|
|
121
|
+
if combatant.position is None and _place_at_edge(state, combatant):
|
|
122
|
+
placed.append(combatant)
|
|
123
|
+
return placed
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def wall_squares(field: Battlefield) -> set[Square]:
|
|
127
|
+
"""The blocked squares."""
|
|
128
|
+
return {grid.parse(s, field) for s in field.walls}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def difficult_squares(field: Battlefield) -> set[Square]:
|
|
132
|
+
"""The squares of difficult terrain."""
|
|
133
|
+
return {grid.parse(s, field) for s in field.difficult}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _place_at_edge(state: GameState, combatant: Combatant) -> bool:
|
|
137
|
+
field = require(state)
|
|
138
|
+
side = grid.SIZE_SQUARES[combatant.size]
|
|
139
|
+
columns: Sequence[int] = range(field.width - side + 1)
|
|
140
|
+
if not is_party(combatant):
|
|
141
|
+
columns = list(reversed(columns))
|
|
142
|
+
middle = (field.height - side) / 2
|
|
143
|
+
rows = sorted(range(field.height - side + 1), key=lambda row: abs(row - middle))
|
|
144
|
+
for column in columns:
|
|
145
|
+
for row in rows:
|
|
146
|
+
if free(state, combatant, (column, row)):
|
|
147
|
+
combatant.position = grid.name((column, row))
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _names(field: Battlefield, areas: Iterable[str]) -> list[str]:
|
|
153
|
+
squares = {square for text in areas for square in grid.area(text, field)}
|
|
154
|
+
return [grid.name(s) for s in sorted(squares, key=lambda s: (s[1], s[0]))]
|