From 67d91291d34720aa99c4f5538c3b452def3d158b Mon Sep 17 00:00:00 2001 From: Allegro Date: Tue, 31 Mar 2026 16:24:18 +0000 Subject: [PATCH] =?UTF-8?q?build:=20second=20pass=20=E2=80=94=20rich=20des?= =?UTF-8?q?criptions,=20custom=20commands,=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- README.md | 193 +++++++++++--- commands/command.py | 493 ++++++++++++++++++++++-------------- commands/default_cmdsets.py | 25 +- world/rebuild_world.py | 482 +++++++++++++++++++++++++++++++++++ 4 files changed, 961 insertions(+), 232 deletions(-) create mode 100644 world/rebuild_world.py diff --git a/README.md b/README.md index fe62bd3..a5c8bfd 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,175 @@ -# Welcome to Evennia! +# Timmy Academy -This is your game directory, set up to let you start with -your new game right away. An overview of this directory is found here: -https://github.com/evennia/evennia/wiki/Directory-Overview#the-game-directory +**An Evennia MUD for AI agent training, collaboration, and crisis response practice.** -You can delete this readme file when you've read it and you can -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. +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. -Your game's main configuration file is found in -`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 +## Why It Exists - 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 -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 -also log into the web client by pointing a browser to -`http://localhost:4001`. +| Method | Address | +|--------|---------| +| Telnet | `telnet 167.99.126.228 4000` | +| Web Client | `http://167.99.126.228: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: -https://github.com/evennia/evennia/wiki. + [Dormitory Wing] + | + [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."* diff --git a/commands/command.py b/commands/command.py index c525d37..a9c9a62 100644 --- a/commands/command.py +++ b/commands/command.py @@ -1,8 +1,14 @@ """ -Commands - -Commands describe the input the account can do to the game. +Timmy Academy - Custom Commands +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 @@ -10,9 +16,14 @@ from evennia import default_cmds class Command(BaseCommand): + """Base command class for Timmy Academy.""" pass +# ============================================================ +# Original commands (from first pass) +# ============================================================ + class CmdExamine(BaseCommand): """ Examine an object, character, or detail in the room. @@ -21,7 +32,7 @@ class CmdExamine(BaseCommand): examine [] ex [] - If no object is given, examines the current room. + If no object is given, examines the current room in detail. """ key = "examine" aliases = ["ex", "exam"] @@ -29,31 +40,36 @@ class CmdExamine(BaseCommand): help_category = "General" def func(self): - """Handle the examination""" if not self.args: - # Examine the room itself - self.caller.msg(f"|c{self.caller.location.key}|n") - self.caller.msg(f"{self.caller.location.db.desc}") + loc = self.caller.location + self.caller.msg(f"|c{loc.key}|n") + 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 - 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: - self.caller.msg(f"\n|wYou see:|n {', '.join(contents)}") + self.caller.msg(f"\n|wPresent:|n {', '.join(contents)}") return - # Search for the object target = self.caller.search(self.args.strip()) if not target: return - - # Show examination self.caller.msg(f"|c{target.key}|n") if hasattr(target, 'db') and target.db.desc: self.caller.msg(f"{target.db.desc}") else: 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): @@ -68,187 +84,284 @@ class CmdRooms(BaseCommand): help_category = "General" def func(self): - """List rooms""" from evennia.objects.models import ObjectDB - - rooms = ObjectDB.objects.filter(db_typeclass_path__contains='Room') - self.caller.msg("|cAvailable Rooms:|n") - for room in rooms[:20]: # Limit to 20 - self.caller.msg(f" {room.db_key}") + rooms = ObjectDB.objects.filter( + db_typeclass_path__icontains='room' + ).order_by('id') + self.caller.msg("|c=== Timmy Academy Rooms ===|n") + 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) - - 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). + Show your current status: location, wing, who's online, uptime. + Usage: + @status """ + key = "@status" + aliases = ["status"] + locks = "cmd:all()" + help_category = "Academy" - # Each Command class implements the following methods, called in this order - # (only func() is actually required): - # - # - at_pre_cmd(): If this returns anything truthy, execution is aborted. - # - parse(): Should perform any extra parsing needed on self.args - # and store the result on self. - # - func(): Performs the actual work. - # - at_post_cmd(): Extra actions, often things done after - # every command, like prompts. - # - pass + def func(self): + caller = self.caller + loc = caller.location + wing = loc.db.wing if loc else "unknown" + mood = "" + if loc and loc.db.atmosphere: + mood = loc.db.atmosphere.get("mood", "") + + # Who is online + from evennia.accounts.models import AccountDB + 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)) -# ------------------------------------------------------------- -# -# The default commands inherit from -# -# 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. -# -# ------------------------------------------------------------- +class CmdMap(BaseCommand): + """ + Show an ASCII map of the current wing or the academy hub. -# from evennia.utils import utils -# -# -# class MuxCommand(Command): -# """ -# This sets up the basis for a MUX command. The idea -# is that most other Mux-related commands should just -# inherit from this and don't have to implement much -# parsing of their own unless they do something particularly -# advanced. -# -# Note that the class's __doc__ string (this text) is -# used by Evennia to create the automatic help entry for -# the command, so make sure to document consistently here. -# """ -# def has_perm(self, srcobj): -# """ -# This is called by the cmdhandler to determine -# if srcobj is allowed to execute this command. -# We just show it here for completeness - we -# are satisfied using the default check in Command. -# """ -# return super().has_perm(srcobj) -# -# def at_pre_cmd(self): -# """ -# This hook is called before self.parse() on all commands -# """ -# pass -# -# def at_post_cmd(self): -# """ -# This hook is called after the command has finished executing -# (after self.func()). -# """ -# pass -# -# def parse(self): -# """ -# This method is called by the cmdhandler once the command name -# has been identified. It creates a new set of member variables -# that can be later accessed from self.func() (see below) -# -# The following variables are available for our use when entering this -# method (from the command definition, and assigned on the fly by the -# cmdhandler): -# self.key - the name of this command ('look') -# self.aliases - the aliases of this cmd ('l') -# self.permissions - permission string for this command -# self.help_category - overall category of command -# -# self.caller - the object calling this command -# self.cmdstring - the actual command name used to call this -# (this allows you to know which alias was used, -# for example) -# self.args - the raw input; everything following self.cmdstring. -# self.cmdset - the cmdset from which this command was picked. Not -# often used (useful for commands like 'help' or to -# list all available commands etc) -# self.obj - the object on which this command was defined. It is often -# the same as self.caller. -# -# A MUX command has the following possible syntax: -# -# name[ with several words][/switch[/switch..]] arg1[,arg2,...] [[=|,] arg[,..]] -# -# 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 -# it here). The rest of the command is stored in self.args, which can -# start with the switch indicator /. -# -# This parser breaks self.args into its constituents and stores them in the -# following variables: -# self.switches = [list of /switches (without the /)] -# self.raw = This is the raw argument input, including switches -# self.args = This is re-defined to be everything *except* the switches -# self.lhs = Everything to the left of = (lhs:'left-hand side'). If -# no = is found, this is identical to self.args. -# self.rhs: Everything to the right of = (rhs:'right-hand side'). -# If no '=' is found, this is None. -# self.lhslist - [self.lhs split into a list by comma] -# self.rhslist - [list of self.rhs split into a list by comma] -# 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. -# """ -# raw = self.args -# args = raw.strip() -# -# # split out switches -# switches = [] -# 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) -# if len(switches) > 1: -# switches, args = switches -# switches = switches.split('/') -# else: -# args = "" -# switches = switches[0].split('/') -# arglist = [arg.strip() for arg in args.split()] -# -# # check for arg1, arg2, ... = argA, argB, ... constructs -# lhs, rhs = args, None -# lhslist, rhslist = [arg.strip() for arg in args.split(',')], [] -# if args and '=' in args: -# lhs, rhs = [arg.strip() for arg in args.split('=', 1)] -# lhslist = [arg.strip() for arg in lhs.split(',')] -# rhslist = [arg.strip() for arg in rhs.split(',')] -# -# # save to object properties: -# self.raw = raw -# self.switches = switches -# self.args = args.strip() -# self.arglist = arglist -# self.lhs = lhs -# self.lhslist = lhslist -# self.rhs = rhs -# self.rhslist = rhslist -# -# # if the class has the account_caller property set on itself, we make -# # sure that self.caller is always the account if possible. We also create -# # 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: -# if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"): -# # caller is an Object/Character -# self.character = self.caller -# self.caller = self.caller.account -# elif utils.inherits_from(self.caller, "evennia.accounts.accounts.DefaultAccount"): -# # caller was already an Account -# self.character = self.caller.get_puppet(self.session) -# else: -# self.character = None + Usage: + @map + """ + key = "@map" + aliases = ["map"] + locks = "cmd:all()" + help_category = "Academy" + + WING_MAPS = { + "hub": ( + "\n|c============= Academy Central Hub =============|n\n" + "\n" + " |c[Dormitory Wing]|n\n" + " |\n" + " north\n" + " |\n" + " |g[Gardens Wing]|n --west-- |w[ LIMBO ]|n --east-- |y[Commons Wing]|n\n" + " |\n" + " south\n" + " |\n" + " |r[Workshop Wing]|n\n" + "\n|c=================================================|n" + ), + "dormitory": ( + "\n|c============= Dormitory Wing =============|n\n" + "\n" + " |c[Master Suites]|n --w-- |c[Corridor]|n --e-- |c[Novice Hall]|n\n" + " |\n" + " south\n" + " |\n" + " |c[Res. Services]|n --n-- |c[Dorm Entrance]|n\n" + " |\n" + " south\n" + " |\n" + " |w[LIMBO/Hub]|n\n" + "\n|c=============================================|n" + ), + "commons": ( + "\n|y============= Commons Wing =============|n\n" + "\n" + " |y[Upper Balcony]|n\n" + " |\n" + " up\n" + " |\n" + " |y[Scholar's]|n --e-- |y[Grand Commons]|n --s-- |y[Entertainment]|n\n" + " |\n" + " north\n" + " |\n" + " |y[Hearthside]|n\n" + " |\n" + " |w[LIMBO/Hub]|n\n" + "\n|y=============================================|n" + ), + "workshop": ( + "\n|r============= Workshop Wing =============|n\n" + "\n" + " |r[Woodworking]|n --e-- |r[Alchemy Labs]|n\n" + " |\n" + " north\n" + " |\n" + " |r[Great Smithy]|n --e-- |r[Workshop Entrance]|n\n" + " |\n" + " down | north\n" + " |\n" + " |r[Artificing]|n |w[LIMBO/Hub]|n\n" + "\n|r=============================================|n" + ), + "gardens": ( + "\n|g============= Gardens Wing =============|n\n" + "\n" + " |g[Sacred Grove]|n\n" + " |\n" + " up\n" + " |\n" + " |g[Greenhouse]|n --n-- |g[Enchanted Grove]|n\n" + " |\n" + " west\n" + " |\n" + " |g[Herb Gardens]|n --s-- |g[Gardens Entrance]|n\n" + " |\n" + " |w[LIMBO/Hub]|n\n" + "\n|g=============================================|n" + ), + } + + def func(self): + loc = self.caller.location + wing = loc.db.wing if loc else "hub" + if not wing or wing not in self.WING_MAPS: + wing = "hub" + self.caller.msg(self.WING_MAPS[wing]) + self.caller.msg(f" |wYou are in:|n {loc.key}") + + +class CmdAcademy(BaseCommand): + """ + Show an overview of all academy wings and room counts. + + Usage: + @academy + """ + key = "@academy" + aliases = ["academy"] + locks = "cmd:all()" + help_category = "Academy" + + def func(self): + from evennia.objects.models import ObjectDB + + msg = [] + msg.append("|c" + "=" * 52 + "|n") + msg.append("|c TIMMY ACADEMY - Agent Training Grounds|n") + msg.append("|c" + "=" * 52 + "|n") + msg.append("") + msg.append(' |x"Together we learn, together we grow."|n') + msg.append("") + + wing_config = { + "hub": {"name": "Central Hub", "color": "|w", "ids": [2]}, + "dormitory": {"name": "Dormitory Wing", "color": "|c", "ids": [3,4,5,6,7]}, + "commons": {"name": "Commons Wing", "color": "|y", "ids": [8,9,10,11,12]}, + "workshop": {"name": "Workshop Wing", "color": "|r", "ids": [13,14,15,16,17]}, + "gardens": {"name": "Gardens Wing", "color": "|g", "ids": [18,19,20,21,22]}, + } + + total_rooms = 0 + for key, info in wing_config.items(): + rooms = ObjectDB.objects.filter(id__in=info["ids"]) + count = rooms.count() + total_rooms += count + c = info["color"] + msg.append(f" {c}{info['name']:20s}|n {count} rooms") + for room in rooms.order_by('id'): + marker = "*" if self.caller.location == room else " " + 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.") diff --git a/commands/default_cmdsets.py b/commands/default_cmdsets.py index 5e1fa2a..f148e2c 100644 --- a/commands/default_cmdsets.py +++ b/commands/default_cmdsets.py @@ -15,7 +15,11 @@ own cmdsets by inheriting from them or directly from `evennia.CmdSet`. """ 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): @@ -32,11 +36,15 @@ class CharacterCmdSet(default_cmds.CharacterCmdSet): Populates the cmdset """ super().at_cmdset_creation() - # - # any commands you add below will overload the default ones. - # + # First pass commands self.add(CmdExamine) 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): @@ -54,9 +62,6 @@ class AccountCmdSet(default_cmds.AccountCmdSet): Populates the cmdset """ super().at_cmdset_creation() - # - # any commands you add below will overload the default ones. - # class UnloggedinCmdSet(default_cmds.UnloggedinCmdSet): @@ -72,9 +77,6 @@ class UnloggedinCmdSet(default_cmds.UnloggedinCmdSet): Populates the cmdset """ super().at_cmdset_creation() - # - # any commands you add below will overload the default ones. - # class SessionCmdSet(default_cmds.SessionCmdSet): @@ -94,6 +96,3 @@ class SessionCmdSet(default_cmds.SessionCmdSet): It prints some info. """ super().at_cmdset_creation() - # - # any commands you add below will overload the default ones. - # diff --git a/world/rebuild_world.py b/world/rebuild_world.py new file mode 100644 index 0000000..3baf6e2 --- /dev/null +++ b/world/rebuild_world.py @@ -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()