Compare commits
3 Commits
fix/room-d
...
build/seco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c77981585 | ||
|
|
67d91291d3 | ||
| b0f53b8fdc |
193
README.md
193
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."*
|
||||
|
||||
@@ -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 [<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"
|
||||
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.")
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
10
hermes-agent/.env
Normal file
10
hermes-agent/.env
Normal file
@@ -0,0 +1,10 @@
|
||||
KIMI_API_KEY=sk-kimi-p17P5TggTzeU2NWc8tTrjKAU2D2jw9BxffvzjtDxyj56b7irb35jvjEJ1Q3PsOPq
|
||||
|
||||
TELEGRAM_BOT_TOKEN=8528070173:AAFrGRb9YxD4XOFEYQhjq_8Cv4zjdqhN5eI
|
||||
|
||||
TELEGRAM_HOME_CHANNEL=-1003664764329
|
||||
|
||||
TELEGRAM_HOME_CHANNEL_NAME="Timmy Time"
|
||||
|
||||
TELEGRAM_ALLOWED_USERS=7635059073
|
||||
GITEA_TOKEN=6452d913d7bdeb21bd13fb6d8067d693e62a7417
|
||||
53
hermes-agent/config.yaml
Normal file
53
hermes-agent/config.yaml
Normal file
@@ -0,0 +1,53 @@
|
||||
# Hermes Agent Fallback Configuration
|
||||
# Deploy this to Timmy and Ezra for automatic kimi-coding fallback
|
||||
|
||||
model: anthropic/claude-opus-4.6
|
||||
|
||||
# Fallback chain: Anthropic -> Kimi -> Ollama (local)
|
||||
fallback_providers:
|
||||
- provider: kimi-coding
|
||||
model: kimi-for-coding
|
||||
timeout: 60
|
||||
reason: "Primary fallback when Anthropic quota limited"
|
||||
|
||||
- provider: ollama
|
||||
model: qwen2.5:7b
|
||||
base_url: http://localhost:11434
|
||||
timeout: 120
|
||||
reason: "Local fallback for offline operation"
|
||||
|
||||
# Provider settings
|
||||
providers:
|
||||
anthropic:
|
||||
timeout: 30
|
||||
retry_on_quota: true
|
||||
max_retries: 2
|
||||
|
||||
kimi-coding:
|
||||
timeout: 60
|
||||
max_retries: 3
|
||||
|
||||
ollama:
|
||||
timeout: 120
|
||||
keep_alive: true
|
||||
|
||||
# Toolsets
|
||||
toolsets:
|
||||
- hermes-cli
|
||||
- github
|
||||
- web
|
||||
|
||||
# Agent settings
|
||||
agent:
|
||||
max_turns: 90
|
||||
tool_use_enforcement: auto
|
||||
fallback_on_errors:
|
||||
- rate_limit_exceeded
|
||||
- quota_exceeded
|
||||
- timeout
|
||||
- service_unavailable
|
||||
|
||||
# Display settings
|
||||
display:
|
||||
show_fallback_notifications: true
|
||||
show_provider_switches: true
|
||||
@@ -64,6 +64,127 @@ GAME_INDEX_LISTING = {
|
||||
}
|
||||
|
||||
|
||||
######################################################################
|
||||
# FULL AUDIT MODE - Track everything
|
||||
######################################################################
|
||||
|
||||
# Log all commands typed by players
|
||||
COMMAND_LOG_ENABLED = True
|
||||
COMMAND_LOG_LEVEL = "DEBUG"
|
||||
COMMAND_LOG_FILENAME = "command_audit.log"
|
||||
|
||||
# Enable detailed account logging
|
||||
AUDIT_LOG_ENABLED = True
|
||||
|
||||
# Custom logging configuration for full audit trail
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"timestamped": {
|
||||
"format": "%(asctime)s [%(levelname)s]: %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
"detailed": {
|
||||
"format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "timestamped",
|
||||
"level": "INFO",
|
||||
},
|
||||
"file_server": {
|
||||
"class": "logging.handlers.TimedRotatingFileHandler",
|
||||
"filename": "server/logs/server.log",
|
||||
"when": "midnight",
|
||||
"backupCount": 30,
|
||||
"formatter": "detailed",
|
||||
"level": "INFO",
|
||||
},
|
||||
"file_portal": {
|
||||
"class": "logging.handlers.TimedRotatingFileHandler",
|
||||
"filename": "server/logs/portal.log",
|
||||
"when": "midnight",
|
||||
"backupCount": 30,
|
||||
"formatter": "detailed",
|
||||
"level": "INFO",
|
||||
},
|
||||
# NEW: Command audit log - tracks every command
|
||||
"file_commands": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": "server/logs/command_audit.log",
|
||||
"maxBytes": 10485760, # 10MB
|
||||
"backupCount": 50,
|
||||
"formatter": "detailed",
|
||||
"level": "DEBUG",
|
||||
},
|
||||
# NEW: Movement audit log - tracks room transitions
|
||||
"file_movement": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": "server/logs/movement_audit.log",
|
||||
"maxBytes": 10485760, # 10MB
|
||||
"backupCount": 50,
|
||||
"formatter": "detailed",
|
||||
"level": "DEBUG",
|
||||
},
|
||||
# NEW: Player activity log
|
||||
"file_player": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": "server/logs/player_activity.log",
|
||||
"maxBytes": 10485760, # 10MB
|
||||
"backupCount": 50,
|
||||
"formatter": "detailed",
|
||||
"level": "DEBUG",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"evennia": {
|
||||
"handlers": ["console", "file_server"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"portal": {
|
||||
"handlers": ["console", "file_portal"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
# NEW: Command audit logger
|
||||
"evennia.commands": {
|
||||
"handlers": ["file_commands"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
# NEW: Movement audit logger
|
||||
"evennia.objects": {
|
||||
"handlers": ["file_movement"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
# NEW: Player activity logger
|
||||
"evennia.accounts": {
|
||||
"handlers": ["file_player"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "WARNING",
|
||||
},
|
||||
}
|
||||
|
||||
# Store additional character state for audit trail
|
||||
CHARACTER_ATTRIBUTES_DEFAULT = {
|
||||
"last_location": None,
|
||||
"location_history": [],
|
||||
"command_count": 0,
|
||||
"playtime_seconds": 0,
|
||||
"last_command_time": None,
|
||||
}
|
||||
|
||||
######################################################################
|
||||
# Settings given in secret_settings.py override those in this file.
|
||||
######################################################################
|
||||
|
||||
109
typeclasses/audited_character.py
Normal file
109
typeclasses/audited_character.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
AuditedCharacter - A character typeclass with full audit logging.
|
||||
|
||||
Tracks every movement, command, and action for complete visibility
|
||||
into player activity.
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from evennia import DefaultCharacter
|
||||
from evennia.utils import logger
|
||||
|
||||
|
||||
class AuditedCharacter(DefaultCharacter):
|
||||
"""
|
||||
Character typeclass with comprehensive audit logging.
|
||||
|
||||
Tracks:
|
||||
- Every room entered/exited with timestamps
|
||||
- Total playtime
|
||||
- Command count
|
||||
- Last known location
|
||||
- Full location history
|
||||
"""
|
||||
|
||||
def at_object_creation(self):
|
||||
"""Set up audit attributes when character is created."""
|
||||
super().at_object_creation()
|
||||
|
||||
# Initialize audit tracking attributes
|
||||
self.db.location_history = [] # List of {room, timestamp, action}
|
||||
self.db.command_count = 0
|
||||
self.db.total_playtime = 0 # in seconds
|
||||
self.db.session_start_time = None
|
||||
self.db.last_location = None
|
||||
|
||||
logger.log_info(f"AUDIT: Character '{self.key}' created at {datetime.utcnow()}")
|
||||
|
||||
def at_pre_move(self, destination, **kwargs):
|
||||
"""Called before moving - log departure."""
|
||||
current = self.location
|
||||
if current:
|
||||
logger.log_info(f"AUDIT MOVE: {self.key} leaving {current.key} -> {destination.key if destination else 'None'}")
|
||||
return super().at_pre_move(destination, **kwargs)
|
||||
|
||||
def at_post_move(self, source_location, **kwargs):
|
||||
"""Called after moving - record arrival in audit trail."""
|
||||
destination = self.location
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
|
||||
# Update location history
|
||||
history = self.db.location_history or []
|
||||
history.append({
|
||||
"from": source_location.key if source_location else "Nowhere",
|
||||
"to": destination.key if destination else "Nowhere",
|
||||
"timestamp": timestamp,
|
||||
"coord": getattr(destination, 'db', {}).get('coord', None) if destination else None
|
||||
})
|
||||
# Keep last 1000 movements
|
||||
self.db.location_history = history[-1000:]
|
||||
self.db.last_location = destination.key if destination else None
|
||||
|
||||
# Log to movement audit log
|
||||
logger.log_info(f"AUDIT MOVE: {self.key} arrived at {destination.key if destination else 'None'} from {source_location.key if source_location else 'None'}")
|
||||
|
||||
super().at_post_move(source_location, **kwargs)
|
||||
|
||||
def at_pre_cmd(self, cmd, args):
|
||||
"""Called before executing any command."""
|
||||
# Increment command counter
|
||||
self.db.command_count = (self.db.command_count or 0) + 1
|
||||
self.db.last_command_time = datetime.utcnow().isoformat()
|
||||
|
||||
# Log command (excluding sensitive commands like password)
|
||||
cmd_name = cmd.key if cmd else "unknown"
|
||||
if cmd_name not in ("password", "@password"):
|
||||
logger.log_info(f"AUDIT CMD: {self.key} executed '{cmd_name}' args: '{args[:50] if args else ''}'")
|
||||
|
||||
super().at_pre_cmd(cmd, args)
|
||||
|
||||
def at_pre_puppet(self, account, session, **kwargs):
|
||||
"""Called when account takes control of character."""
|
||||
self.db.session_start_time = time.time()
|
||||
logger.log_info(f"AUDIT SESSION: {self.key} puppeted by {account.key} at {datetime.utcnow()}")
|
||||
super().at_pre_puppet(account, session, **kwargs)
|
||||
|
||||
def at_post_unpuppet(self, account, session, **kwargs):
|
||||
"""Called when account releases control of character."""
|
||||
start_time = self.db.session_start_time
|
||||
if start_time:
|
||||
session_duration = time.time() - start_time
|
||||
self.db.total_playtime = (self.db.total_playtime or 0) + session_duration
|
||||
logger.log_info(f"AUDIT SESSION: {self.key} unpuppeted by {account.key} - session lasted {session_duration:.0f}s, total playtime {self.db.total_playtime:.0f}s")
|
||||
self.db.session_start_time = None
|
||||
super().at_post_unpuppet(account, session, **kwargs)
|
||||
|
||||
def get_audit_summary(self):
|
||||
"""Return a summary of this character's audit trail."""
|
||||
history = self.db.location_history or []
|
||||
return {
|
||||
"name": self.key,
|
||||
"location": self.location.key if self.location else "None",
|
||||
"commands_executed": self.db.command_count or 0,
|
||||
"total_playtime_seconds": self.db.total_playtime or 0,
|
||||
"total_playtime_hours": round((self.db.total_playtime or 0) / 3600, 2),
|
||||
"locations_visited": len(history),
|
||||
"last_location_change": history[-1] if history else None,
|
||||
"last_command": self.db.last_command_time,
|
||||
}
|
||||
482
world/rebuild_world.py
Normal file
482
world/rebuild_world.py
Normal 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()
|
||||
Reference in New Issue
Block a user