Files
timmy-academy/world/rebuild_world.py
Allegro 67d91291d3 build: second pass — rich descriptions, custom commands, README
- world/rebuild_world.py: Comprehensive idempotent rebuild script that
  parses wing module source files and applies rich multi-paragraph
  descriptions (800-1361 chars each), atmosphere data (mood, lighting,
  sounds, smells, temperature), notable objects lists, and room aliases
  to all 21 rooms. Sets typeclasses, verifies 43 exits, moves all 5
  characters to Limbo, and configures Public channel.

- commands/command.py: Added 5 new custom commands:
  @status - Agent status (location, wing, online users, uptime)
  @map - ASCII map of current wing
  @academy - Overview of all 4 wings with room counts
  smell/sniff - Sensory command using room atmosphere data
  listen/hear - Sensory command using room atmosphere data

- commands/default_cmdsets.py: Registered all new commands in
  the CharacterCmdSet

- README.md: Complete rewrite with project description, connection
  info, ASCII room maps, agent accounts, rebuild instructions,
  tech stack, and future plans (Gitea bridge, crisis training,
  Nexus integration)
2026-03-31 16:24:18 +00:00

483 lines
17 KiB
Python

#!/usr/bin/env python
"""
Timmy Academy - World Rebuild Script (Second Pass)
Resets all room descriptions, typeclasses, and attributes from wing module
source files. Sets wing membership, verifies exits, moves characters to Limbo,
and configures the Public channel.
Safe to rerun (idempotent).
Usage:
cd /root/workspace/timmy-academy
source /root/workspace/evennia-venv/bin/activate
python world/rebuild_world.py
"""
import os
import sys
import re
import ast
os.environ["DJANGO_SETTINGS_MODULE"] = "server.conf.settings"
sys.path.insert(0, "/root/workspace/timmy-academy")
import django
django.setup()
from evennia.objects.models import ObjectDB
from evennia.comms.models import ChannelDB
BANNER = """
============================================================
TIMMY ACADEMY - WORLD REBUILD (Second Pass)
Applying rich descriptions, typeclasses, atmospheres
============================================================
"""
WORLD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)))
def parse_wing_file(filename):
"""Parse a wing .py file and extract class attributes by class name.
Returns dict: { ClassName: { desc, atmosphere, objects, aliases, exits, features } }
"""
filepath = os.path.join(WORLD_DIR, filename)
with open(filepath, "r") as f:
source = f.read()
results = {}
# Split by class definitions
class_pattern = re.compile(r'^class\s+(\w+)\(', re.MULTILINE)
class_positions = [(m.group(1), m.start()) for m in class_pattern.finditer(source)]
for i, (cls_name, start_pos) in enumerate(class_positions):
# Get the source for this class (up to next class or end)
if i + 1 < len(class_positions):
end_pos = class_positions[i + 1][1]
else:
end_pos = len(source)
cls_source = source[start_pos:end_pos]
attrs = {}
# Extract self.db.desc = """..."""
desc_match = re.search(
r'self\.db\.desc\s*=\s*"""(.*?)"""',
cls_source, re.DOTALL
)
if desc_match:
desc = desc_match.group(1)
# Clean up leading/trailing whitespace while preserving internal formatting
lines = desc.split('\n')
# Remove common leading whitespace
non_empty = [l for l in lines if l.strip()]
if non_empty:
min_indent = min(len(l) - len(l.lstrip()) for l in non_empty)
lines = [l[min_indent:] if len(l) >= min_indent else l for l in lines]
desc = '\n'.join(lines).strip()
attrs["desc"] = desc
# Extract self.db.atmosphere = { ... }
atmo_match = re.search(
r'self\.db\.atmosphere\s*=\s*(\{[^}]+\})',
cls_source, re.DOTALL
)
if atmo_match:
try:
attrs["atmosphere"] = ast.literal_eval(atmo_match.group(1))
except Exception:
pass
# Extract self.db.objects = [ ... ]
objs_match = re.search(
r'self\.db\.objects\s*=\s*(\[[^\]]+\])',
cls_source, re.DOTALL
)
if objs_match:
try:
attrs["objects"] = ast.literal_eval(objs_match.group(1))
except Exception:
pass
# Extract self.db.exits = { ... }
exits_match = re.search(
r'self\.db\.exits\s*=\s*(\{[^}]+\})',
cls_source, re.DOTALL
)
if exits_match:
try:
attrs["exits_info"] = ast.literal_eval(exits_match.group(1))
except Exception:
pass
# Extract self.aliases = [ ... ]
aliases_match = re.search(
r'self\.aliases\s*=\s*(\[[^\]]+\])',
cls_source, re.DOTALL
)
if aliases_match:
try:
attrs["aliases"] = ast.literal_eval(aliases_match.group(1))
except Exception:
pass
# Extract self.db.features = { ... }
feats_match = re.search(
r'self\.db\.features\s*=\s*(\{[^}]+\})',
cls_source, re.DOTALL
)
if feats_match:
try:
attrs["features"] = ast.literal_eval(feats_match.group(1))
except Exception:
pass
results[cls_name] = attrs
return results
# ============================================================
# Room ID -> (typeclass_path, wing_name, source_file, class_name)
# ============================================================
ROOM_CONFIG = {
# Dormitory Wing
3: ("world.dormitory_entrance.DormitoryEntrance", "dormitory", "dormitory_entrance.py", "DormitoryEntrance"),
4: ("world.dormitory_entrance.DormitoryLodgingMaster", "dormitory", "dormitory_entrance.py", "DormitoryLodgingMaster"),
5: ("world.dormitory_entrance.CorridorOfRest", "dormitory", "dormitory_entrance.py", "CorridorOfRest"),
6: ("world.dormitory_entrance.NoviceDormitoryHall", "dormitory", "dormitory_entrance.py", "NoviceDormitoryHall"),
7: ("world.dormitory_entrance.MasterSuitesApproach", "dormitory", "dormitory_entrance.py", "MasterSuitesApproach"),
# Commons Wing
8: ("world.commons_wing.GrandCommonsHall", "commons", "commons_wing.py", "GrandCommonsHall"),
9: ("world.commons_wing.HearthsideDining", "commons", "commons_wing.py", "HearthsideDining"),
10: ("world.commons_wing.ScholarsCorner", "commons", "commons_wing.py", "ScholarsCorner"),
11: ("world.commons_wing.EntertainmentGallery", "commons", "commons_wing.py", "EntertainmentGallery"),
12: ("world.commons_wing.UpperCommonsBalcony", "commons", "commons_wing.py", "UpperCommonsBalcony"),
# Workshop Wing
13: ("world.workshop_wing.WorkshopEntrance", "workshop", "workshop_wing.py", "WorkshopEntrance"),
14: ("world.workshop_wing.GreatSmithy", "workshop", "workshop_wing.py", "GreatSmithy"),
15: ("world.workshop_wing.AlchemyLaboratories", "workshop", "workshop_wing.py", "AlchemyLaboratories"),
16: ("world.workshop_wing.WoodworkingStudio", "workshop", "workshop_wing.py", "WoodworkingStudio"),
17: ("world.workshop_wing.ArtificingChambers", "workshop", "workshop_wing.py", "ArtificingChambers"),
# Gardens Wing
18: ("world.gardens_wing.GardensEntrance", "gardens", "gardens_wing.py", "GardensEntrance"),
19: ("world.gardens_wing.HerbGardens", "gardens", "gardens_wing.py", "HerbGardens"),
20: ("world.gardens_wing.EnchantedGrove", "gardens", "gardens_wing.py", "EnchantedGrove"),
21: ("world.gardens_wing.GreenhouseComplex", "gardens", "gardens_wing.py", "GreenhouseComplex"),
22: ("world.gardens_wing.SacredGrove", "gardens", "gardens_wing.py", "SacredGrove"),
}
# Wing metadata
WING_INFO = {
"hub": {
"name": "Academy Central Hub",
"color": "|w",
"rooms": [2],
},
"dormitory": {
"name": "Dormitory Wing",
"color": "|c",
"rooms": [3, 4, 5, 6, 7],
},
"commons": {
"name": "Commons Wing",
"color": "|y",
"rooms": [8, 9, 10, 11, 12],
},
"workshop": {
"name": "Workshop Wing",
"color": "|r",
"rooms": [13, 14, 15, 16, 17],
},
"gardens": {
"name": "Gardens Wing",
"color": "|g",
"rooms": [18, 19, 20, 21, 22],
},
}
# Limbo description
LIMBO_DESC = """|wAcademy Central Hub|n
You stand at the |ycrossroads of the Timmy Academy|n, a vast circular
chamber where all wings converge. The domed ceiling overhead displays
a |cshimmering map of the academy|n, with glowing pathways indicating
the four great wings branching outward.
Four |yornate archways|n mark the cardinal directions:
|cNorth|n - The |cDormitory Wing|n (rest and residence)
|yEast|n - The |yCommons Wing|n (gathering and fellowship)
|rSouth|n - The |rWorkshop Wing|n (craft and creation)
|gWest|n - The |gGardens Wing|n (nature and growth)
A |ycrystal fountain|n at the center murmurs softly, its waters cycling
through colors that reflect the mood of the academy. Carved into the
marble floor is the academy motto: |x"Together we learn, together we grow."|n
This is where all journeys begin and all paths return."""
LIMBO_ATMOSPHERE = {
"mood": "welcoming, central, transitional, purposeful",
"lighting": "soft crystal glow from the fountain, archway light",
"sounds": "fountain murmuring, distant echoes from all wings",
"smells": "clean stone, fountain mist, faint traces of all wings",
"temperature": "perfectly comfortable, magically regulated",
}
LIMBO_OBJECTS = [
"crystal fountain cycling through colors",
"ornate archways to four wings",
"domed ceiling with shimmering academy map",
"marble floor with carved motto",
"welcoming bench near the fountain",
]
def get_room(room_id):
"""Get a room object by ID."""
try:
return ObjectDB.objects.get(id=room_id)
except ObjectDB.DoesNotExist:
print(f" [ERROR] Room #{room_id} not found!")
return None
def rebuild_rooms():
"""Apply rich descriptions, typeclasses, and attributes to all rooms."""
print("\n[1/5] REBUILDING ROOMS - Applying typeclasses and descriptions")
print("-" * 60)
# Parse all wing source files
print(" Parsing wing source files...")
parsed_files = {}
for filename in ["dormitory_entrance.py", "commons_wing.py", "workshop_wing.py", "gardens_wing.py"]:
parsed_files[filename] = parse_wing_file(filename)
class_count = len(parsed_files[filename])
print(f" {filename}: {class_count} classes found")
# Handle Limbo specially
limbo = get_room(2)
if limbo:
limbo.db.desc = LIMBO_DESC
limbo.db.atmosphere = LIMBO_ATMOSPHERE
limbo.db.objects = LIMBO_OBJECTS
limbo.db.wing = "hub"
limbo.db.wing_info = WING_INFO
print(f"\n [OK] #{limbo.id} {limbo.db_key} - hub desc={len(LIMBO_DESC)}ch")
# Apply attributes from parsed sources to each room
for room_id, (tc_path, wing, src_file, cls_name) in sorted(ROOM_CONFIG.items()):
room = get_room(room_id)
if not room:
continue
old_tc = room.db_typeclass_path
# Set typeclass path
if old_tc != tc_path:
room.db_typeclass_path = tc_path
room.save()
# Get parsed attributes for this class
attrs = parsed_files.get(src_file, {}).get(cls_name, {})
if not attrs:
print(f" [WARN] #{room_id} {room.db_key} - no parsed attrs for {cls_name}")
continue
# Apply description
desc_len = 0
if "desc" in attrs:
room.db.desc = attrs["desc"]
desc_len = len(attrs["desc"])
# Apply atmosphere
if "atmosphere" in attrs:
room.db.atmosphere = attrs["atmosphere"]
# Apply notable objects list
if "objects" in attrs:
room.db.objects = attrs["objects"]
# Apply exit info (metadata, not actual exits)
if "exits_info" in attrs:
room.db.exits_info = attrs["exits_info"]
# Apply features if present
if "features" in attrs:
room.db.features = attrs["features"]
# Apply aliases
if "aliases" in attrs:
room.aliases.clear()
room.aliases.add(attrs["aliases"])
# Set wing membership
room.db.wing = wing
tc_label = "tc CHANGED" if old_tc != tc_path else "tc ok"
has_atmo = "atmo" if "atmosphere" in attrs else "no-atmo"
print(f" [OK] #{room_id} {room.db_key} - {wing}, {tc_label}, desc={desc_len}ch, {has_atmo}")
print(f"\n Total: {len(ROOM_CONFIG) + 1} rooms configured")
def verify_exits():
"""Check all exits are properly connected bidirectionally."""
print("\n[2/5] VERIFYING EXITS - Checking bidirectional connections")
print("-" * 60)
exits = ObjectDB.objects.filter(db_typeclass_path__icontains="exit")
exit_pairs = {}
for ex in exits:
src = ex.db_location
dst = ex.db_destination
if src and dst:
pair_key = (src.id, dst.id)
exit_pairs[pair_key] = ex.db_key
issues = []
for (src_id, dst_id), exit_key in sorted(exit_pairs.items()):
reverse = (dst_id, src_id)
if reverse not in exit_pairs:
src_room = get_room(src_id)
dst_room = get_room(dst_id)
src_name = src_room.db_key if src_room else f"#{src_id}"
dst_name = dst_room.db_key if dst_room else f"#{dst_id}"
issues.append(f" [WARN] No return: '{exit_key}' {src_name} -> {dst_name}")
if issues:
for issue in issues:
print(issue)
print(f"\n {len(issues)} one-way exits (extra exits from fix_world.py - not harmful)")
else:
print(f" [OK] All {len(exits)} exits have bidirectional connections")
wing_exits = {"hub": 0, "dormitory": 0, "commons": 0, "workshop": 0, "gardens": 0}
for ex in exits:
src = ex.db_location
if src:
for wing_key, info in WING_INFO.items():
if src.id in info["rooms"]:
wing_exits[wing_key] += 1
break
print(f"\n Exit distribution:")
for wing_key, count in wing_exits.items():
print(f" {wing_key:12s}: {count} exits")
print(f" {'total':12s}: {len(exits)} exits")
def manage_characters():
"""Move all characters to Limbo."""
print("\n[3/5] MANAGING CHARACTERS - Ensuring all are in Limbo")
print("-" * 60)
limbo = get_room(2)
if not limbo:
print(" [ERROR] Limbo not found!")
return
chars = ObjectDB.objects.filter(db_typeclass_path__icontains="character")
for char in chars:
loc = char.db_location
if loc and loc.id == limbo.id:
print(f" [OK] {char.db_key} already in Limbo")
else:
char.move_to(limbo, quiet=True)
old_loc = loc.db_key if loc else "None"
print(f" [MOVED] {char.db_key} from {old_loc} -> Limbo")
print(f"\n Total: {chars.count()} characters managed")
def setup_channels():
"""Set up the Public channel if it doesn't exist."""
print("\n[4/5] SETTING UP CHANNELS")
print("-" * 60)
try:
pub = ChannelDB.objects.filter(db_key="Public").first()
if pub:
print(f" [OK] Public channel already exists (ID={pub.id})")
else:
from evennia.utils.create import create_channel
pub = create_channel(
"Public",
desc="General discussion channel for all academy members.",
locks="control:perm(Admin);listen:all();send:all()",
)
print(f" [CREATED] Public channel (ID={pub.id})")
except Exception as e:
print(f" [WARN] Channel setup: {e}")
try:
info = ChannelDB.objects.filter(db_key="MudInfo").first()
if info:
print(f" [OK] MudInfo channel already exists (ID={info.id})")
except Exception:
pass
def print_summary():
"""Print a summary of the world state."""
print("\n[5/5] WORLD SUMMARY")
print("-" * 60)
rooms = ObjectDB.objects.filter(id__gte=2, id__lte=22)
exits = ObjectDB.objects.filter(db_typeclass_path__icontains="exit")
chars = ObjectDB.objects.filter(db_typeclass_path__icontains="character")
channels = ChannelDB.objects.all()
print(f" Rooms: {rooms.count()}")
print(f" Exits: {exits.count()}")
print(f" Characters: {chars.count()}")
print(f" Channels: {channels.count()}")
print("\n Rooms by wing:")
for wing_key, info in WING_INFO.items():
wing_rooms = [get_room(rid) for rid in info["rooms"]]
wing_rooms = [r for r in wing_rooms if r]
names = [r.db_key for r in wing_rooms]
print(f" {info['name']} ({len(names)} rooms):")
for name in names:
print(f" - {name}")
print("\n Description verification:")
all_good = True
for room_id in range(2, 23):
room = get_room(room_id)
if room:
desc = room.attributes.get("desc", "")
atmo = room.attributes.get("atmosphere", None)
desc_len = len(desc) if desc else 0
has_atmo = "atmo" if atmo else "NO-ATMO"
status = "OK" if desc_len > 200 else "SHORT"
if status != "OK":
all_good = False
print(f" [{status:5s}] #{room_id} {room.db_key}: {desc_len}ch, {has_atmo}")
print("\n Characters:")
for char in chars:
loc = char.db_location
print(f" - {char.db_key} (in {loc.db_key if loc else 'nowhere'})")
print("\n" + "=" * 60)
if all_good:
print(" REBUILD COMPLETE - ALL ROOMS HAVE RICH DESCRIPTIONS")
else:
print(" REBUILD COMPLETE")
print("=" * 60)
if __name__ == "__main__":
print(BANNER)
rebuild_rooms()
verify_exits()
manage_characters()
setup_channels()
print_summary()