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 /path/to/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, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
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()
|