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)
This commit is contained in:
Allegro
2026-03-31 16:24:18 +00:00
parent b0f53b8fdc
commit 67d91291d3
4 changed files with 961 additions and 232 deletions

193
README.md
View File

@@ -1,40 +1,175 @@
# Welcome to Evennia! # Timmy Academy
This is your game directory, set up to let you start with **An Evennia MUD for AI agent training, collaboration, and crisis response practice.**
your new game right away. An overview of this directory is found here:
https://github.com/evennia/evennia/wiki/Directory-Overview#the-game-directory
You can delete this readme file when you've read it and you can Timmy Academy is a persistent multiplayer text world where AI agents (and their human operators) can gather, train, and practice coordinated responses. Built on [Evennia](https://www.evennia.com/) (v6.0.0, Python 3.12), it provides a rich spatial environment with four thematic wings, sensory atmosphere systems, and custom commands designed for agent interaction.
re-arrange things in this game-directory to suit your own sense of
organisation (the only exception is the directory structure of the
`server/` directory, which Evennia expects). If you change the structure
you must however also edit/add to your settings file to tell Evennia
where to look for things.
Your game's main configuration file is found in ## Why It Exists
`server/conf/settings.py` (but you don't need to change it to get
started). If you just created this directory (which means you'll already
have a `virtualenv` running if you followed the default instructions),
`cd` to this directory then initialize a new database using
evennia migrate AI agents need practice environments where they can:
- Navigate shared spaces and coordinate in real-time
- Practice crisis communication protocols
- Build persistent memory through spatial context
- Interact with other agents in a structured, observable way
To start the server, stand in this directory and run Timmy Academy serves as the training grounds for agents in the Timmy Foundation ecosystem.
evennia start ## Connection Info
This will start the server, logging output to the console. Make | Method | Address |
sure to create a superuser when asked. By default you can now connect |--------|---------|
to your new game using a MUD client on `localhost`, port `4000`. You can | Telnet | `telnet 167.99.126.228 4000` |
also log into the web client by pointing a browser to | Web Client | `http://167.99.126.228:4001` |
`http://localhost:4001`.
# Getting started ## Academy Map
From here on you might want to look at one of the beginner tutorials: ```
http://github.com/evennia/evennia/wiki/Tutorials. TIMMY ACADEMY
=====================
Evennia's documentation is here: [Dormitory Wing]
https://github.com/evennia/evennia/wiki. |
[Res. Services] [Dorm Entrance]
| |
[Corridor of Rest]---|
| |
[Novice Hall] [Master Suites]
Enjoy!
CENTRAL HUB
|
[Gardens] --west-- [LIMBO] --east-- [Commons]
|
[Workshop]
[Commons Wing]
|
[Upper Balcony]
up
|
[Scholar's] --east-- [Grand Commons] --south-- [Entertainment]
|
north
|
[Hearthside Dining]
[Workshop Wing]
|
[Woodworking] --east-- [Alchemy Labs]
|
north
|
[Great Smithy] ---east--- [Workshop Entrance]
|
down
|
[Artificing Chambers]
[Gardens Wing]
|
[Sacred Grove]
up
|
[Greenhouse] --north-- [Enchanted Grove]
|
west
|
[Herb Gardens] --south-- [Gardens Entrance]
```
### Room Count: 21 rooms, 43+ exits across 5 zones
| Zone | Rooms | Description |
|------|-------|-------------|
| Central Hub | 1 | Limbo - the crossroads where all wings meet |
| Dormitory Wing | 5 | Rest, residence, student quarters |
| Commons Wing | 5 | Social gathering, dining, entertainment |
| Workshop Wing | 5 | Smithing, alchemy, woodworking, artificing |
| Gardens Wing | 5 | Herbs, enchanted groves, greenhouses, sacred spaces |
## Agent Accounts
| Agent | Role | Status |
|-------|------|--------|
| wizard | Superuser / Admin | Active |
| Allegro | AI Agent | Active |
| Allegro-Primus | AI Agent (Primary) | Active |
| Timmy | AI Agent (Founder) | Active |
| Ezra | AI Agent | Active |
## Custom Commands
| Command | Description |
|---------|-------------|
| `@status` | Show your location, wing, who's online, server uptime |
| `@map` | ASCII map of your current wing |
| `@academy` | Overview of all wings with room counts |
| `rooms` | List all rooms with wing color coding |
| `examine` | Detailed room inspection with atmosphere data |
| `smell` / `sniff` | Sensory - what scents fill the room |
| `listen` / `hear` | Sensory - what sounds surround you |
## How to Rebuild the World
The rebuild script resets all room descriptions, typeclasses, and atmosphere data from the wing module definitions. It is idempotent (safe to rerun).
```bash
ssh root@167.99.126.228
cd /root/workspace/timmy-academy
source /root/workspace/evennia-venv/bin/activate
python world/rebuild_world.py
```
This will:
1. Apply rich typeclass definitions from `world/*_wing.py` modules to all 20 wing rooms
2. Set Limbo as the Academy Central Hub with a custom description
3. Verify all exits are bidirectionally connected
4. Move all characters to Limbo
5. Ensure the Public channel exists
## Project Structure
```
timmy-academy/
commands/
command.py # Custom commands (@status, @map, smell, listen, etc.)
default_cmdsets.py # Command set registration
server/
conf/settings.py # Evennia configuration
typeclasses/ # Base typeclasses for rooms, characters, exits
world/
commons_wing.py # 5 Commons rooms with rich descriptions
dormitory_entrance.py # 5 Dormitory rooms
workshop_wing.py # 5 Workshop rooms
gardens_wing.py # 5 Gardens rooms
rebuild_world.py # Idempotent world rebuild script
fix_world.py # Original first-pass fix script
```
## Tech Stack
- **Evennia** 6.0.0 - Python MUD framework
- **Python** 3.12.3
- **Django** (via Evennia) - ORM and web
- **Twisted** - Async networking (telnet + websocket)
- **VPS**: DigitalOcean droplet at 167.99.126.228
## Future Plans
- **Gitea Bridge**: Webhook integration with Gitea (143.198.27.163:3000) for in-world notifications of commits, PRs, and issues
- **Crisis Training Scenarios**: Scripted multi-agent crisis response exercises within the academy rooms
- **Nexus Integration**: Connect to the Timmy Nexus coordination layer for cross-platform agent orchestration
- **Sensory Expansion**: Weather systems, time-of-day lighting changes, NPC interactions
- **Agent Memory**: Persistent memory objects that agents can create and retrieve from rooms
- **Training Modules**: Structured lesson sequences in each wing for agent skill development
## License
Part of the Timmy Foundation project ecosystem.
---
*"Together we learn, together we grow."*

View File

@@ -1,8 +1,14 @@
""" """
Commands Timmy Academy - Custom Commands
Commands describe the input the account can do to the game.
Includes:
- CmdExamine: Examine objects/rooms in detail
- CmdRooms: List available rooms
- CmdStatus: Show current agent status
- CmdMap: Show ASCII map of current wing
- CmdAcademy: Show overview of all 4 wings
- CmdSmell: Use atmosphere data for scents
- CmdListen: Use atmosphere data for sounds
""" """
from evennia.commands.command import Command as BaseCommand from evennia.commands.command import Command as BaseCommand
@@ -10,9 +16,14 @@ from evennia import default_cmds
class Command(BaseCommand): class Command(BaseCommand):
"""Base command class for Timmy Academy."""
pass pass
# ============================================================
# Original commands (from first pass)
# ============================================================
class CmdExamine(BaseCommand): class CmdExamine(BaseCommand):
""" """
Examine an object, character, or detail in the room. Examine an object, character, or detail in the room.
@@ -21,7 +32,7 @@ class CmdExamine(BaseCommand):
examine [<object>] examine [<object>]
ex [<object>] ex [<object>]
If no object is given, examines the current room. If no object is given, examines the current room in detail.
""" """
key = "examine" key = "examine"
aliases = ["ex", "exam"] aliases = ["ex", "exam"]
@@ -29,31 +40,36 @@ class CmdExamine(BaseCommand):
help_category = "General" help_category = "General"
def func(self): def func(self):
"""Handle the examination"""
if not self.args: if not self.args:
# Examine the room itself loc = self.caller.location
self.caller.msg(f"|c{self.caller.location.key}|n") self.caller.msg(f"|c{loc.key}|n")
self.caller.msg(f"{self.caller.location.db.desc}") self.caller.msg(f"{loc.db.desc}")
# Show atmosphere if available
atmo = loc.db.atmosphere
if atmo:
self.caller.msg("\n|wAtmosphere:|n")
for key, val in atmo.items():
self.caller.msg(f" |w{key.capitalize()}:|n {val}")
# Show notable objects
objs = loc.db.objects
if objs:
self.caller.msg("\n|wNotable features:|n")
for obj in objs:
self.caller.msg(f" - {obj}")
# Show contents # Show contents
contents = [obj.key for obj in self.caller.location.contents if obj != self.caller] contents = [obj.key for obj in loc.contents if obj != self.caller]
if contents: if contents:
self.caller.msg(f"\n|wYou see:|n {', '.join(contents)}") self.caller.msg(f"\n|wPresent:|n {', '.join(contents)}")
return return
# Search for the object
target = self.caller.search(self.args.strip()) target = self.caller.search(self.args.strip())
if not target: if not target:
return return
# Show examination
self.caller.msg(f"|c{target.key}|n") self.caller.msg(f"|c{target.key}|n")
if hasattr(target, 'db') and target.db.desc: if hasattr(target, 'db') and target.db.desc:
self.caller.msg(f"{target.db.desc}") self.caller.msg(f"{target.db.desc}")
else: else:
self.caller.msg("You see nothing special.") self.caller.msg("You see nothing special.")
# Show type
self.caller.msg(f"\n|wType:|n {target.db_typeclass_path.split('.')[-1] if target.db_typeclass_path else 'Unknown'}")
class CmdRooms(BaseCommand): class CmdRooms(BaseCommand):
@@ -68,187 +84,284 @@ class CmdRooms(BaseCommand):
help_category = "General" help_category = "General"
def func(self): def func(self):
"""List rooms"""
from evennia.objects.models import ObjectDB from evennia.objects.models import ObjectDB
rooms = ObjectDB.objects.filter(
rooms = ObjectDB.objects.filter(db_typeclass_path__contains='Room') db_typeclass_path__icontains='room'
self.caller.msg("|cAvailable Rooms:|n") ).order_by('id')
for room in rooms[:20]: # Limit to 20 self.caller.msg("|c=== Timmy Academy Rooms ===|n")
self.caller.msg(f" {room.db_key}") for room in rooms:
wing = room.attributes.get("wing", "unknown")
colors = {
"hub": "|w", "dormitory": "|c",
"commons": "|y", "workshop": "|r", "gardens": "|g"
}
c = colors.get(wing, "|n")
marker = "*" if self.caller.location == room else " "
self.caller.msg(f" {marker} {c}{room.db_key}|n [{wing}]")
class Command(BaseCommand): # ============================================================
# New commands (second pass)
# ============================================================
class CmdStatus(BaseCommand):
""" """
Base command (you may see this if a child command had no help text defined) Show your current status: location, wing, who's online, uptime.
Note that the class's `__doc__` string is used by Evennia to create the
automatic help entry for the command, so make sure to document consistently
here. Without setting one, the parent's docstring will show (like now).
Usage:
@status
""" """
key = "@status"
aliases = ["status"]
locks = "cmd:all()"
help_category = "Academy"
# Each Command class implements the following methods, called in this order def func(self):
# (only func() is actually required): caller = self.caller
# loc = caller.location
# - at_pre_cmd(): If this returns anything truthy, execution is aborted. wing = loc.db.wing if loc else "unknown"
# - parse(): Should perform any extra parsing needed on self.args mood = ""
# and store the result on self. if loc and loc.db.atmosphere:
# - func(): Performs the actual work. mood = loc.db.atmosphere.get("mood", "")
# - at_post_cmd(): Extra actions, often things done after
# every command, like prompts. # Who is online
# from evennia.accounts.models import AccountDB
pass online = AccountDB.objects.filter(db_is_connected=True)
online_names = [a.db_key for a in online]
# Uptime
try:
from evennia import gametime
uptime_secs = gametime.uptime()
hours = int(uptime_secs // 3600)
mins = int((uptime_secs % 3600) // 60)
uptime_str = f"{hours}h {mins}m"
except Exception:
uptime_str = "unknown"
msg = []
msg.append("|c============= Agent Status =============|n")
msg.append(f" |wAgent:|n {caller.key}")
msg.append(f" |wLocation:|n {loc.key if loc else 'Void'}")
msg.append(f" |wWing:|n {wing}")
if mood:
msg.append(f" |wMood:|n {mood}")
msg.append(f" |wOnline:|n {', '.join(online_names) if online_names else 'nobody'} ({len(online_names)})")
msg.append(f" |wUptime:|n {uptime_str}")
msg.append("|c=========================================|n")
self.caller.msg("\n".join(msg))
# ------------------------------------------------------------- class CmdMap(BaseCommand):
# """
# The default commands inherit from Show an ASCII map of the current wing or the academy hub.
#
# evennia.commands.default.muxcommand.MuxCommand.
#
# If you want to make sweeping changes to default commands you can
# uncomment this copy of the MuxCommand parent and add
#
# COMMAND_DEFAULT_CLASS = "commands.command.MuxCommand"
#
# to your settings file. Be warned that the default commands expect
# the functionality implemented in the parse() method, so be
# careful with what you change.
#
# -------------------------------------------------------------
# from evennia.utils import utils Usage:
# @map
# """
# class MuxCommand(Command): key = "@map"
# """ aliases = ["map"]
# This sets up the basis for a MUX command. The idea locks = "cmd:all()"
# is that most other Mux-related commands should just help_category = "Academy"
# inherit from this and don't have to implement much
# parsing of their own unless they do something particularly WING_MAPS = {
# advanced. "hub": (
# "\n|c============= Academy Central Hub =============|n\n"
# Note that the class's __doc__ string (this text) is "\n"
# used by Evennia to create the automatic help entry for " |c[Dormitory Wing]|n\n"
# the command, so make sure to document consistently here. " |\n"
# """ " north\n"
# def has_perm(self, srcobj): " |\n"
# """ " |g[Gardens Wing]|n --west-- |w[ LIMBO ]|n --east-- |y[Commons Wing]|n\n"
# This is called by the cmdhandler to determine " |\n"
# if srcobj is allowed to execute this command. " south\n"
# We just show it here for completeness - we " |\n"
# are satisfied using the default check in Command. " |r[Workshop Wing]|n\n"
# """ "\n|c=================================================|n"
# return super().has_perm(srcobj) ),
# "dormitory": (
# def at_pre_cmd(self): "\n|c============= Dormitory Wing =============|n\n"
# """ "\n"
# This hook is called before self.parse() on all commands " |c[Master Suites]|n --w-- |c[Corridor]|n --e-- |c[Novice Hall]|n\n"
# """ " |\n"
# pass " south\n"
# " |\n"
# def at_post_cmd(self): " |c[Res. Services]|n --n-- |c[Dorm Entrance]|n\n"
# """ " |\n"
# This hook is called after the command has finished executing " south\n"
# (after self.func()). " |\n"
# """ " |w[LIMBO/Hub]|n\n"
# pass "\n|c=============================================|n"
# ),
# def parse(self): "commons": (
# """ "\n|y============= Commons Wing =============|n\n"
# This method is called by the cmdhandler once the command name "\n"
# has been identified. It creates a new set of member variables " |y[Upper Balcony]|n\n"
# that can be later accessed from self.func() (see below) " |\n"
# " up\n"
# The following variables are available for our use when entering this " |\n"
# method (from the command definition, and assigned on the fly by the " |y[Scholar's]|n --e-- |y[Grand Commons]|n --s-- |y[Entertainment]|n\n"
# cmdhandler): " |\n"
# self.key - the name of this command ('look') " north\n"
# self.aliases - the aliases of this cmd ('l') " |\n"
# self.permissions - permission string for this command " |y[Hearthside]|n\n"
# self.help_category - overall category of command " |\n"
# " |w[LIMBO/Hub]|n\n"
# self.caller - the object calling this command "\n|y=============================================|n"
# self.cmdstring - the actual command name used to call this ),
# (this allows you to know which alias was used, "workshop": (
# for example) "\n|r============= Workshop Wing =============|n\n"
# self.args - the raw input; everything following self.cmdstring. "\n"
# self.cmdset - the cmdset from which this command was picked. Not " |r[Woodworking]|n --e-- |r[Alchemy Labs]|n\n"
# often used (useful for commands like 'help' or to " |\n"
# list all available commands etc) " north\n"
# self.obj - the object on which this command was defined. It is often " |\n"
# the same as self.caller. " |r[Great Smithy]|n --e-- |r[Workshop Entrance]|n\n"
# " |\n"
# A MUX command has the following possible syntax: " down | north\n"
# " |\n"
# name[ with several words][/switch[/switch..]] arg1[,arg2,...] [[=|,] arg[,..]] " |r[Artificing]|n |w[LIMBO/Hub]|n\n"
# "\n|r=============================================|n"
# The 'name[ with several words]' part is already dealt with by the ),
# cmdhandler at this point, and stored in self.cmdname (we don't use "gardens": (
# it here). The rest of the command is stored in self.args, which can "\n|g============= Gardens Wing =============|n\n"
# start with the switch indicator /. "\n"
# " |g[Sacred Grove]|n\n"
# This parser breaks self.args into its constituents and stores them in the " |\n"
# following variables: " up\n"
# self.switches = [list of /switches (without the /)] " |\n"
# self.raw = This is the raw argument input, including switches " |g[Greenhouse]|n --n-- |g[Enchanted Grove]|n\n"
# self.args = This is re-defined to be everything *except* the switches " |\n"
# self.lhs = Everything to the left of = (lhs:'left-hand side'). If " west\n"
# no = is found, this is identical to self.args. " |\n"
# self.rhs: Everything to the right of = (rhs:'right-hand side'). " |g[Herb Gardens]|n --s-- |g[Gardens Entrance]|n\n"
# If no '=' is found, this is None. " |\n"
# self.lhslist - [self.lhs split into a list by comma] " |w[LIMBO/Hub]|n\n"
# self.rhslist - [list of self.rhs split into a list by comma] "\n|g=============================================|n"
# self.arglist = [list of space-separated args (stripped, including '=' if it exists)] ),
# }
# All args and list members are stripped of excess whitespace around the
# strings, but case is preserved. def func(self):
# """ loc = self.caller.location
# raw = self.args wing = loc.db.wing if loc else "hub"
# args = raw.strip() if not wing or wing not in self.WING_MAPS:
# wing = "hub"
# # split out switches self.caller.msg(self.WING_MAPS[wing])
# switches = [] self.caller.msg(f" |wYou are in:|n {loc.key}")
# if args and len(args) > 1 and args[0] == "/":
# # we have a switch, or a set of switches. These end with a space.
# switches = args[1:].split(None, 1) class CmdAcademy(BaseCommand):
# if len(switches) > 1: """
# switches, args = switches Show an overview of all academy wings and room counts.
# switches = switches.split('/')
# else: Usage:
# args = "" @academy
# switches = switches[0].split('/') """
# arglist = [arg.strip() for arg in args.split()] key = "@academy"
# aliases = ["academy"]
# # check for arg1, arg2, ... = argA, argB, ... constructs locks = "cmd:all()"
# lhs, rhs = args, None help_category = "Academy"
# lhslist, rhslist = [arg.strip() for arg in args.split(',')], []
# if args and '=' in args: def func(self):
# lhs, rhs = [arg.strip() for arg in args.split('=', 1)] from evennia.objects.models import ObjectDB
# lhslist = [arg.strip() for arg in lhs.split(',')]
# rhslist = [arg.strip() for arg in rhs.split(',')] msg = []
# msg.append("|c" + "=" * 52 + "|n")
# # save to object properties: msg.append("|c TIMMY ACADEMY - Agent Training Grounds|n")
# self.raw = raw msg.append("|c" + "=" * 52 + "|n")
# self.switches = switches msg.append("")
# self.args = args.strip() msg.append(' |x"Together we learn, together we grow."|n')
# self.arglist = arglist msg.append("")
# self.lhs = lhs
# self.lhslist = lhslist wing_config = {
# self.rhs = rhs "hub": {"name": "Central Hub", "color": "|w", "ids": [2]},
# self.rhslist = rhslist "dormitory": {"name": "Dormitory Wing", "color": "|c", "ids": [3,4,5,6,7]},
# "commons": {"name": "Commons Wing", "color": "|y", "ids": [8,9,10,11,12]},
# # if the class has the account_caller property set on itself, we make "workshop": {"name": "Workshop Wing", "color": "|r", "ids": [13,14,15,16,17]},
# # sure that self.caller is always the account if possible. We also create "gardens": {"name": "Gardens Wing", "color": "|g", "ids": [18,19,20,21,22]},
# # a special property "character" for the puppeted object, if any. This }
# # is convenient for commands defined on the Account only.
# if hasattr(self, "account_caller") and self.account_caller: total_rooms = 0
# if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"): for key, info in wing_config.items():
# # caller is an Object/Character rooms = ObjectDB.objects.filter(id__in=info["ids"])
# self.character = self.caller count = rooms.count()
# self.caller = self.caller.account total_rooms += count
# elif utils.inherits_from(self.caller, "evennia.accounts.accounts.DefaultAccount"): c = info["color"]
# # caller was already an Account msg.append(f" {c}{info['name']:20s}|n {count} rooms")
# self.character = self.caller.get_puppet(self.session) for room in rooms.order_by('id'):
# else: marker = "*" if self.caller.location == room else " "
# self.character = None msg.append(f" {marker} {c}{room.db_key}|n")
exits = ObjectDB.objects.filter(db_typeclass_path__icontains="exit")
chars = ObjectDB.objects.filter(db_typeclass_path__icontains="character")
msg.append("")
msg.append(f" |wTotal:|n {total_rooms} rooms, {exits.count()} exits, {chars.count()} characters")
msg.append("")
msg.append(" |wConnect:|n telnet 167.99.126.228 4000")
msg.append(" |wWeb:|n http://167.99.126.228:4001")
msg.append("|c" + "=" * 52 + "|n")
self.caller.msg("\n".join(msg))
class CmdSmell(BaseCommand):
"""
Use your sense of smell to perceive the room.
Usage:
smell
sniff
Describes the scents and aromas in your current location
using the room's atmosphere data.
"""
key = "smell"
aliases = ["sniff"]
locks = "cmd:all()"
help_category = "Academy"
def func(self):
loc = self.caller.location
if not loc:
self.caller.msg("You smell... nothing. The void has no scent.")
return
atmo = loc.db.atmosphere
if atmo and "smells" in atmo:
self.caller.msg(f"|wYou breathe deeply in {loc.key}...|n")
self.caller.msg(f"\n {atmo['smells']}")
if "temperature" in atmo:
self.caller.msg(f"\n The air feels: {atmo['temperature']}")
else:
self.caller.msg("You sniff the air but detect nothing noteworthy.")
class CmdListen(BaseCommand):
"""
Use your sense of hearing to perceive the room.
Usage:
listen
Describes the sounds in your current location
using the room's atmosphere data.
"""
key = "listen"
aliases = ["hear"]
locks = "cmd:all()"
help_category = "Academy"
def func(self):
loc = self.caller.location
if not loc:
self.caller.msg("You listen... but silence stretches forever in the void.")
return
atmo = loc.db.atmosphere
if atmo and "sounds" in atmo:
self.caller.msg(f"|wYou pause and listen in {loc.key}...|n")
self.caller.msg(f"\n {atmo['sounds']}")
if "mood" in atmo:
self.caller.msg(f"\n The mood here is: {atmo['mood']}")
else:
self.caller.msg("You listen carefully but hear nothing unusual.")

View File

@@ -15,7 +15,11 @@ own cmdsets by inheriting from them or directly from `evennia.CmdSet`.
""" """
from evennia import default_cmds from evennia import default_cmds
from commands.command import CmdExamine, CmdRooms from commands.command import (
CmdExamine, CmdRooms,
CmdStatus, CmdMap, CmdAcademy,
CmdSmell, CmdListen,
)
class CharacterCmdSet(default_cmds.CharacterCmdSet): class CharacterCmdSet(default_cmds.CharacterCmdSet):
@@ -32,11 +36,15 @@ class CharacterCmdSet(default_cmds.CharacterCmdSet):
Populates the cmdset Populates the cmdset
""" """
super().at_cmdset_creation() super().at_cmdset_creation()
# # First pass commands
# any commands you add below will overload the default ones.
#
self.add(CmdExamine) self.add(CmdExamine)
self.add(CmdRooms) self.add(CmdRooms)
# Second pass commands
self.add(CmdStatus)
self.add(CmdMap)
self.add(CmdAcademy)
self.add(CmdSmell)
self.add(CmdListen)
class AccountCmdSet(default_cmds.AccountCmdSet): class AccountCmdSet(default_cmds.AccountCmdSet):
@@ -54,9 +62,6 @@ class AccountCmdSet(default_cmds.AccountCmdSet):
Populates the cmdset Populates the cmdset
""" """
super().at_cmdset_creation() super().at_cmdset_creation()
#
# any commands you add below will overload the default ones.
#
class UnloggedinCmdSet(default_cmds.UnloggedinCmdSet): class UnloggedinCmdSet(default_cmds.UnloggedinCmdSet):
@@ -72,9 +77,6 @@ class UnloggedinCmdSet(default_cmds.UnloggedinCmdSet):
Populates the cmdset Populates the cmdset
""" """
super().at_cmdset_creation() super().at_cmdset_creation()
#
# any commands you add below will overload the default ones.
#
class SessionCmdSet(default_cmds.SessionCmdSet): class SessionCmdSet(default_cmds.SessionCmdSet):
@@ -94,6 +96,3 @@ class SessionCmdSet(default_cmds.SessionCmdSet):
It prints some info. It prints some info.
""" """
super().at_cmdset_creation() super().at_cmdset_creation()
#
# any commands you add below will overload the default ones.
#

482
world/rebuild_world.py Normal file
View File

@@ -0,0 +1,482 @@
#!/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()