Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
b3359e1bae feat: add Bezalel Evennia world scaffold (#536)
Some checks are pending
Smoke Test / smoke (pull_request) Waiting to run
2026-04-15 00:46:03 -04:00
4 changed files with 539 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
# Bezalel Evennia World
Issue: `timmy-home#536`
This is the themed-room world plan and build scaffold for Bezalel, the forge-and-testbed wizard.
## Rooms
| Room | Description focus | Core connections |
|------|-------------------|------------------|
| Limbo | the threshold between houses | Gatehouse |
| Gatehouse | guarded entry, travel runes, proof before trust | Limbo, Great Hall, The Portal Room |
| Great Hall | three-house maps, reports, shared table | Gatehouse, The Library of Bezalel, The Observatory, The Workshop |
| The Library of Bezalel | manuals, bridge schematics, technical memory | Great Hall |
| The Observatory | long-range signals toward Mac, VPS, and the wider net | Great Hall |
| The Workshop | forge + workbench, plans turned into working form | Great Hall, The Server Room, The Garden of Code |
| The Server Room | humming racks, heartbeat of the house | The Workshop |
| The Garden of Code | contemplative grove where ideas root before implementation | The Workshop |
| The Portal Room | three shimmering doorways aimed at Mac, VPS, and the net | Gatehouse |
## Characters
| Character | Role | Starting room |
|-----------|------|---------------|
| Timmy | quiet builder and observer | Gatehouse |
| Bezalel | forge-and-testbed wizard | The Workshop |
| Marcus | old man with kind eyes, human warmth in the system | The Garden of Code |
| Kimi | scholar of context and meaning | The Library of Bezalel |
## Themed items
At least one durable item is placed in every major room, including:
- Threshold Ledger
- Three-House Map
- Bridge Schematics
- Compiler Manuals
- Tri-Axis Telescope
- Forge Anvil
- Bridge Workbench
- Heartbeat Console
- Server Racks
- Code Orchard
- Stone Bench
- Mac/VPS/Net portal markers
## Portal travel commands
The Portal Room reserves three live command names:
- `mac`
- `vps`
- `net`
Current behavior in the build scaffold:
- each command is created as a real Evennia exit command
- each command preserves explicit target metadata (`Mac house`, `VPS house`, `Wider net`)
- until cross-world transport is wired, each portal routes through `Limbo`, the inter-world threshold room
This keeps the command surface real now while leaving honest room for later world-to-world linking.
## Build script
```bash
python3 scripts/evennia/build_bezalel_world.py --plan
```
Inside an Evennia shell / runtime with the repo on `PYTHONPATH`, the same script can build the world idempotently:
```bash
python3 scripts/evennia/build_bezalel_world.py --password bezalel-world-dev
```
What it does:
- creates or updates all 9 rooms
- creates the exit graph
- creates themed objects
- creates or rehomes account-backed characters
- creates the portal command exits with target metadata
## Persistence note
The scaffold is written to be idempotent: rerunning the builder updates descriptions, destinations, and locations rather than creating duplicate world entities. That is the repo-side prerequisite for persistence across Evennia restarts.

View File

@@ -0,0 +1,190 @@
from collections import deque
from dataclasses import dataclass
@dataclass(frozen=True)
class RoomSpec:
key: str
desc: str
@dataclass(frozen=True)
class ExitSpec:
source: str
key: str
destination: str
aliases: tuple[str, ...] = ()
@dataclass(frozen=True)
class ObjectSpec:
key: str
location: str
desc: str
aliases: tuple[str, ...] = ()
@dataclass(frozen=True)
class CharacterSpec:
key: str
desc: str
starting_room: str
@dataclass(frozen=True)
class TravelCommandSpec:
key: str
aliases: tuple[str, ...]
target_world: str
fallback_room: str
desc: str
ROOMS = (
RoomSpec(
"Limbo",
"The void between worlds. The air carries the pulse of three houses: Mac, VPS, and this one. "
"Everything begins here before it is given form.",
),
RoomSpec(
"Gatehouse",
"A stone guard tower at the edge of Bezalel's world. The walls are carved with runes of travel, "
"proof, and return. Every arrival is weighed before it is trusted.",
),
RoomSpec(
"Great Hall",
"A vast hall with a long working table. Maps of the three houses hang beside sketches, benchmarks, "
"and deployment notes. This is where the forge reports back to the house.",
),
RoomSpec(
"The Library of Bezalel",
"Shelves of technical manuals, Evennia code, test logs, and bridge schematics rise to the ceiling. "
"This room holds plans waiting to be made real.",
),
RoomSpec(
"The Observatory",
"A high chamber with telescopes pointing toward the Mac, the VPS, and the wider net. Screens glow with "
"status lights, latency traces, and long-range signals.",
),
RoomSpec(
"The Workshop",
"A forge and workbench share the same heat. Scattered here are half-finished bridges, patched harnesses, "
"and tools laid out for proof before pride.",
),
RoomSpec(
"The Server Room",
"Racks of humming servers line the walls. Fans push warm air through the chamber while status LEDs beat "
"like a mechanical heart. This is the pulse of Bezalel's house.",
),
RoomSpec(
"The Garden of Code",
"A quiet garden where ideas are left long enough to grow roots. Code-shaped leaves flutter in patterned wind, "
"and a stone path invites patient thought.",
),
RoomSpec(
"The Portal Room",
"Three shimmering doorways stand in a ring: one marked for the Mac house, one for the VPS, and one for the wider net. "
"The room hums like a bridge waiting for traffic.",
),
)
EXITS = (
ExitSpec("Limbo", "gatehouse", "Gatehouse", ("gate", "tower")),
ExitSpec("Gatehouse", "limbo", "Limbo", ("void", "back")),
ExitSpec("Gatehouse", "greathall", "Great Hall", ("hall", "great hall")),
ExitSpec("Great Hall", "gatehouse", "Gatehouse", ("gate", "tower")),
ExitSpec("Great Hall", "library", "The Library of Bezalel", ("books", "study")),
ExitSpec("The Library of Bezalel", "hall", "Great Hall", ("great hall", "back")),
ExitSpec("Great Hall", "observatory", "The Observatory", ("telescope", "tower top")),
ExitSpec("The Observatory", "hall", "Great Hall", ("great hall", "back")),
ExitSpec("Great Hall", "workshop", "The Workshop", ("forge", "bench")),
ExitSpec("The Workshop", "hall", "Great Hall", ("great hall", "back")),
ExitSpec("The Workshop", "serverroom", "The Server Room", ("servers", "server room")),
ExitSpec("The Server Room", "workshop", "The Workshop", ("forge", "bench")),
ExitSpec("The Workshop", "garden", "The Garden of Code", ("garden of code", "grove")),
ExitSpec("The Garden of Code", "workshop", "The Workshop", ("forge", "bench")),
ExitSpec("Gatehouse", "portalroom", "The Portal Room", ("portal", "portals")),
ExitSpec("The Portal Room", "gatehouse", "Gatehouse", ("gate", "back")),
)
OBJECTS = (
ObjectSpec("Threshold Ledger", "Gatehouse", "A heavy ledger where arrivals, departures, and field notes are recorded before the work begins."),
ObjectSpec("Three-House Map", "Great Hall", "A long map showing Mac, VPS, and remote edges in one continuous line of work."),
ObjectSpec("Bridge Schematics", "The Library of Bezalel", "Rolled plans describing world bridges, Evennia layouts, and deployment paths."),
ObjectSpec("Compiler Manuals", "The Library of Bezalel", "Manuals annotated in the margins with warnings against cleverness without proof."),
ObjectSpec("Tri-Axis Telescope", "The Observatory", "A brass telescope assembly that can be turned toward the Mac, the VPS, or the open net."),
ObjectSpec("Forge Anvil", "The Workshop", "Scarred metal used for turning rough plans into testable form."),
ObjectSpec("Bridge Workbench", "The Workshop", "A wide bench covered in harness patches, relay notes, and half-soldered bridge parts."),
ObjectSpec("Heartbeat Console", "The Server Room", "A monitoring console showing service health, latency, and the steady hum of the house."),
ObjectSpec("Server Racks", "The Server Room", "Stacked machines that keep the world awake even when no one is watching."),
ObjectSpec("Code Orchard", "The Garden of Code", "Trees with code-shaped leaves. Some branches bear elegant abstractions; others hold broken prototypes."),
ObjectSpec("Stone Bench", "The Garden of Code", "A place to sit long enough for a hard implementation problem to become clear."),
ObjectSpec("Mac Portal", "The Portal Room", "A silver doorway whose frame vibrates with the local sovereign house.", ("mac arch",)),
ObjectSpec("VPS Portal", "The Portal Room", "A cobalt doorway tuned toward the testbed VPS house.", ("vps arch",)),
ObjectSpec("Net Portal", "The Portal Room", "A pale doorway pointed toward the wider net and every uncertain edge beyond it.", ("net arch", "network arch")),
)
CHARACTERS = (
CharacterSpec("Timmy", "The Builder's first creation. Quiet, observant, already measuring the room before he speaks.", "Gatehouse"),
CharacterSpec("Bezalel", "The forge-and-testbed wizard. Scarred hands, steady gaze, the habit of proving things before trusting them.", "The Workshop"),
CharacterSpec("Marcus", "An old man with kind eyes. He walks like someone who has already survived the night once.", "The Garden of Code"),
CharacterSpec("Kimi", "The deep scholar of context and meaning. He carries long memory like a lamp.", "The Library of Bezalel"),
)
PORTAL_COMMANDS = (
TravelCommandSpec(
"mac",
("macbook", "local"),
"Mac house",
"Limbo",
"Align with the sovereign local house. Until live cross-world transport is wired, the command resolves into Limbo — the threshold between houses.",
),
TravelCommandSpec(
"vps",
("testbed", "house"),
"VPS house",
"Limbo",
"Step toward the forge VPS. For now the command lands in Limbo, preserving the inter-world threshold until real linking is live.",
),
TravelCommandSpec(
"net",
("network", "wider-net"),
"Wider net",
"Limbo",
"Face the open network. The command currently routes through Limbo so the direction exists before the final bridge does.",
),
)
def room_keys() -> tuple[str, ...]:
return tuple(room.key for room in ROOMS)
def character_keys() -> tuple[str, ...]:
return tuple(character.key for character in CHARACTERS)
def portal_command_keys() -> tuple[str, ...]:
return tuple(command.key for command in PORTAL_COMMANDS)
def grouped_exits() -> dict[str, tuple[ExitSpec, ...]]:
grouped: dict[str, list[ExitSpec]] = {}
for exit_spec in EXITS:
grouped.setdefault(exit_spec.source, []).append(exit_spec)
return {key: tuple(value) for key, value in grouped.items()}
def reachable_rooms_from(start: str) -> set[str]:
seen: set[str] = set()
queue: deque[str] = deque([start])
exits_by_room = grouped_exits()
while queue:
current = queue.popleft()
if current in seen:
continue
seen.add(current)
for exit_spec in exits_by_room.get(current, ()):
if exit_spec.destination not in seen:
queue.append(exit_spec.destination)
return seen

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Idempotent builder for Bezalel's themed Evennia world."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from evennia_tools.bezalel_layout import CHARACTERS, EXITS, OBJECTS, PORTAL_COMMANDS, ROOMS
def describe_build_plan() -> dict:
return {
"room_count": len(ROOMS),
"character_count": len(CHARACTERS),
"object_count": len(OBJECTS),
"portal_command_count": len(PORTAL_COMMANDS),
"room_names": [room.key for room in ROOMS],
"character_starts": {character.key: character.starting_room for character in CHARACTERS},
"portal_commands": [command.key for command in PORTAL_COMMANDS],
}
def _import_evennia_runtime():
from evennia.accounts.models import AccountDB
from evennia.accounts.accounts import DefaultAccount
from evennia.objects.objects import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom
from evennia.utils.create import create_object
from evennia.utils.search import search_object
return {
"AccountDB": AccountDB,
"DefaultAccount": DefaultAccount,
"DefaultCharacter": DefaultCharacter,
"DefaultExit": DefaultExit,
"DefaultObject": DefaultObject,
"DefaultRoom": DefaultRoom,
"create_object": create_object,
"search_object": search_object,
}
def _find_named(search_object, key: str, *, location=None):
matches = search_object(key, exact=True)
if location is None:
return matches[0] if matches else None
for match in matches:
if getattr(match, "location", None) == location:
return match
return None
def _ensure_room(runtime, room_spec):
room = _find_named(runtime["search_object"], room_spec.key)
if room is None:
room = runtime["create_object"](runtime["DefaultRoom"], key=room_spec.key)
room.db.desc = room_spec.desc
room.save()
return room
def _ensure_exit(runtime, exit_spec, room_map):
source = room_map[exit_spec.source]
destination = room_map[exit_spec.destination]
existing = _find_named(runtime["search_object"], exit_spec.key, location=source)
if existing is None:
existing = runtime["create_object"](
runtime["DefaultExit"],
key=exit_spec.key,
aliases=list(exit_spec.aliases),
location=source,
destination=destination,
)
else:
existing.destination = destination
if exit_spec.aliases:
existing.aliases.add(list(exit_spec.aliases))
existing.save()
return existing
def _ensure_object(runtime, object_spec, room_map):
location = room_map[object_spec.location]
existing = _find_named(runtime["search_object"], object_spec.key, location=location)
if existing is None:
existing = runtime["create_object"](
runtime["DefaultObject"],
key=object_spec.key,
aliases=list(object_spec.aliases),
location=location,
home=location,
)
existing.db.desc = object_spec.desc
existing.home = location
if existing.location != location:
existing.move_to(location, quiet=True, move_hooks=False)
existing.save()
return existing
def _ensure_character(runtime, character_spec, room_map, password: str):
account = runtime["AccountDB"].objects.filter(username__iexact=character_spec.key).first()
if account is None:
account, errors = runtime["DefaultAccount"].create(username=character_spec.key, password=password)
if not account:
raise RuntimeError(f"failed to create account for {character_spec.key}: {errors}")
character = list(account.characters)[0]
start = room_map[character_spec.starting_room]
character.db.desc = character_spec.desc
character.home = start
character.move_to(start, quiet=True, move_hooks=False)
character.save()
return character
def _ensure_portal_command(runtime, portal_spec, room_map):
portal_room = room_map["The Portal Room"]
fallback = room_map[portal_spec.fallback_room]
existing = _find_named(runtime["search_object"], portal_spec.key, location=portal_room)
if existing is None:
existing = runtime["create_object"](
runtime["DefaultExit"],
key=portal_spec.key,
aliases=list(portal_spec.aliases),
location=portal_room,
destination=fallback,
)
else:
existing.destination = fallback
if portal_spec.aliases:
existing.aliases.add(list(portal_spec.aliases))
existing.db.desc = portal_spec.desc
existing.db.travel_target = portal_spec.target_world
existing.db.portal_stub = True
existing.save()
return existing
def build_world(password: str = "bezalel-world-dev") -> dict:
runtime = _import_evennia_runtime()
room_map = {room.key: _ensure_room(runtime, room) for room in ROOMS}
for exit_spec in EXITS:
_ensure_exit(runtime, exit_spec, room_map)
for object_spec in OBJECTS:
_ensure_object(runtime, object_spec, room_map)
for character_spec in CHARACTERS:
_ensure_character(runtime, character_spec, room_map, password=password)
for portal_spec in PORTAL_COMMANDS:
_ensure_portal_command(runtime, portal_spec, room_map)
return {
"rooms": [room.key for room in ROOMS],
"characters": {character.key: character.starting_room for character in CHARACTERS},
"portal_commands": {command.key: command.target_world for command in PORTAL_COMMANDS},
}
def main() -> None:
parser = argparse.ArgumentParser(description="Build Bezalel's themed Evennia world")
parser.add_argument("--plan", action="store_true", help="Print the static build plan without importing Evennia")
parser.add_argument("--password", default="bezalel-world-dev", help="Password to use for created account-backed characters")
args = parser.parse_args()
if args.plan:
print(json.dumps(describe_build_plan(), indent=2))
return
print(json.dumps(build_world(password=args.password), indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,90 @@
import importlib.util
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LAYOUT_PATH = ROOT / "evennia_tools" / "bezalel_layout.py"
BUILD_PATH = ROOT / "scripts" / "evennia" / "build_bezalel_world.py"
DOC_PATH = ROOT / "docs" / "BEZALEL_EVENNIA_WORLD.md"
def load_module(path: Path, name: str):
assert path.exists(), f"missing {path.relative_to(ROOT)}"
spec = importlib.util.spec_from_file_location(name, path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class TestBezalelEvenniaLayout(unittest.TestCase):
def test_room_graph_matches_issue_shape(self):
layout = load_module(LAYOUT_PATH, "bezalel_layout")
self.assertEqual(
layout.room_keys(),
(
"Limbo",
"Gatehouse",
"Great Hall",
"The Library of Bezalel",
"The Observatory",
"The Workshop",
"The Server Room",
"The Garden of Code",
"The Portal Room",
),
)
exits = layout.grouped_exits()
self.assertEqual(
{ex.destination for ex in exits["Great Hall"]},
{"Gatehouse", "The Library of Bezalel", "The Observatory", "The Workshop"},
)
self.assertEqual(
{ex.destination for ex in exits["The Workshop"]},
{"Great Hall", "The Server Room", "The Garden of Code"},
)
def test_items_characters_and_portal_commands_are_all_defined(self):
layout = load_module(LAYOUT_PATH, "bezalel_layout")
self.assertEqual(layout.character_keys(), ("Timmy", "Bezalel", "Marcus", "Kimi"))
self.assertGreaterEqual(len(layout.OBJECTS), 9)
self.assertEqual(layout.portal_command_keys(), ("mac", "vps", "net"))
room_names = set(layout.room_keys())
for obj in layout.OBJECTS:
self.assertIn(obj.location, room_names)
for character in layout.CHARACTERS:
self.assertIn(character.starting_room, room_names)
for portal in layout.PORTAL_COMMANDS:
self.assertEqual(portal.fallback_room, "Limbo")
def test_timmy_can_reach_every_room_from_gatehouse(self):
layout = load_module(LAYOUT_PATH, "bezalel_layout")
reachable = layout.reachable_rooms_from("Gatehouse")
self.assertEqual(reachable, set(layout.room_keys()))
def test_build_plan_summary_reports_counts_and_portal_aliases(self):
build = load_module(BUILD_PATH, "build_bezalel_world")
summary = build.describe_build_plan()
self.assertEqual(summary["room_count"], 9)
self.assertEqual(summary["character_count"], 4)
self.assertIn("mac", summary["portal_commands"])
self.assertIn("The Workshop", summary["room_names"])
self.assertEqual(summary["character_starts"]["Bezalel"], "The Workshop")
def test_repo_contains_bezalel_world_doc(self):
self.assertTrue(DOC_PATH.exists(), "missing committed Bezalel world doc")
text = DOC_PATH.read_text(encoding="utf-8")
for snippet in (
"# Bezalel Evennia World",
"## Rooms",
"## Characters",
"## Portal travel commands",
"The Library of Bezalel",
"The Garden of Code",
):
self.assertIn(snippet, text)
if __name__ == "__main__":
unittest.main()