- 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)
483 lines
17 KiB
Python
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()
|