dndkit 0.4.0__py3-none-any.whl

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