Add agent scaffolding for those wanting to work in the Evennia repo

This commit is contained in:
Griatch
2026-04-10 23:58:17 +02:00
parent 7b693d6577
commit 5b9a2acfac
14 changed files with 3398 additions and 1 deletions

View File

@ -0,0 +1,47 @@
# Evennia Architecture
## Two-Process Model
Evennia runs as two cooperating Twisted processes:
- **Portal** (`server/portal/`) — faces the internet, handles all network protocols (telnet, SSH, SSL, websocket). Stays running during reloads.
- **Server** (`server/`) — runs game logic, Django ORM, commands. Can be reloaded without disconnecting players.
They communicate via an internal AMP (Asynchronous Messaging Protocol) connection.
## Typeclass System
The central abstraction. Every persistent game entity has two layers:
- **Database model** (e.g. `ObjectDB`, `AccountDB`, `ScriptDB`, `ChannelDB`) — Django model, stores data in the database.
- **Typeclass** (e.g. `DefaultObject`, `DefaultCharacter`, `DefaultRoom`, `DefaultExit`) — Python class that adds game logic, linked 1:1 to a DB model via `typeclass_path`.
Typeclasses live in `objects/`, `accounts/`, `scripts/`, `comms/`. The DB models are in `*/models.py`, typeclasses in `*/objects.py`, `*/accounts.py`, `*/scripts.py`, `*/comms.py`.
**Attributes and Tags** (`typeclasses/attributes.py`, `typeclasses/tags.py`) are the key-value and labeling systems stored on any typeclassed object. `AttributeProperty` and `TagProperty` allow declaring them as class-level descriptors.
## Command System
`commands/` — Commands are Python classes inheriting from `Command` (or `MuxCommand` for MUX-style parsing). They are grouped into `CmdSet` objects that merge/override each other on objects, accounts, and sessions.
Flow: input → `cmdhandler.py` (dispatch) → `cmdparser.py` (matching) → `Command.func()` execution.
Default commands are in `commands/default/` organized by category: `general.py`, `building.py`, `admin.py`, `comms.py`, `system.py`, `unloggedin.py`, `account.py`, `help.py`.
## Key Subsystems
- **Scripts** (`scripts/`) — Timed/persistent objects. Houses global handlers: `TICKER_HANDLER`, `MONITOR_HANDLER`, `TASK_HANDLER`, `ON_DEMAND_HANDLER`.
- **Locks** (`locks/`) — String-based permission system parsed at runtime. Lock functions in `lockfuncs.py`.
- **Help** (`help/`) — Database-backed help entries plus auto-generated help from command docstrings.
- **Prototypes** (`prototypes/`) — Dict-based templates for spawning objects via `evennia.spawn()`.
- **Web** (`web/`) — Django views, REST API (`web/api/`), webclient (`web/webclient/`), admin interface.
- **Utils** (`utils/`) — `EvMenu`, `EvTable`, `EvForm`, `EvEditor`, `EvMore`, `FuncParser`, `ANSIString`, `search_*` and `create_*` functions.
- **Contrib** (`contrib/`) — Community modules organized as `base_systems/`, `game_systems/`, `rpg/`, `grid/`, `tutorials/`, `utils/`, `full_systems/`.
## Flat API
`evennia/__init__.py` exposes a flat API — most important classes and functions are accessible as `evennia.DefaultObject`, `evennia.search_object()`, `evennia.create_object()`, etc. This API is lazy-loaded after Django initialization via `_init()`.
## Settings
`settings_default.py` (~1850 lines) is the master settings template. Game developers override specific values in their game dir's `server/conf/settings.py`. Never modify `settings_default.py` directly for a game — only when changing Evennia's own defaults.

53
.agents/docs/ci.md Normal file
View File

@ -0,0 +1,53 @@
# CI/CD
## Workflows (`.github/workflows/`)
### Test Suite (`github_action_test_suite.yml`)
Triggers on push/PR to `main` or `develop` (skips docs-only changes).
**Matrix**: Python 3.12, 3.13, 3.14 across three database jobs:
| Job | DB | Timeout | Notes |
|---|---|---|---|
| test-sqlite | SQLite | 30m | Coverage on Python 3.12 only |
| test-mysql | MySQL 8.0 | 35m | utf8mb3 charset, `--keepdb --parallel` |
| test-postgresql | PostgreSQL 14 | 60m | Sequential, no `--keepdb` (stale DB issues) |
**Deploy step** (main branch only, after all tests pass): builds and pushes Docker image `evennia/evennia:latest` to DockerHub.
### Doc Build (`github_action_build_docs.yml`)
Triggers on push/PR to `main`/`develop` when `docs/` or `evennia/contrib/` changes. Builds with Sphinx via `make release` in `docs/`. Requires a full game dir init + migrations before building.
### Other Workflows
- `codeql-analysis.yml` — Security scanning (JS + Python), weekly + on push/PR
- `github_action_issue_to_project.yml` — Auto-adds issues to GitHub Projects
## Custom Actions (`.github/actions/`)
- **`setup-database/`** — Waits for DB readiness, creates MySQL database/users, grants privileges. SQLite needs no setup.
- **`run-tests/`** — Sets up Python, installs Evennia, inits test game dir, copies DB-specific settings from `.github/workflows/{db}_settings.py`, runs migrations, then tests.
## Database Settings (`.github/workflows/`)
Each DB type has a settings file copied into the test game dir:
- `sqlite3_settings.py` — Minimal, uses Evennia defaults
- `mysql_settings.py` — utf8mb3 charset, `STRICT_TRANS_TABLES`, `innodb_strict_mode`
- `postgresql_settings.py` — Aggressive timeouts for CI: `lock_timeout=30s`, `statement_timeout=5m`
## Docker (`Dockerfile`)
- Base: `python:3.13-alpine`
- Build arg `EVENNIA_INSTALL_MODE`: `editable` (default, mounts source) or `pypi`
- Ports: 4000 (telnet), 4001 (web), 4002 (websocket)
- Volume: `/usr/src/game`
- Entrypoint: `bin/unix/evennia-docker-start.sh`
## Secrets
- `COVERALLS_REPO_TOKEN` — Coverage reporting
- `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` — Docker publishing
- `EVENNIA_TICKET_TO_PROJECT` — Issue automation

View File

@ -0,0 +1,64 @@
# Code Style
## Formatting
Do not manually format code. Run `make format` (black + isort) after editing. It handles line length (100 chars), indentation, and import sorting. Use `make lint` to check without modifying.
## Docstrings
All modules, classes, functions, and methods must have docstrings. Use Google-style with Markdown formatting.
Import order (isort handles this, but be aware): stdlib → Twisted → Django → `evennia``evennia.contrib`
### Command Docstrings
Command class docstrings double as in-game help text. They use a special format with **2-space indentation**:
```python
"""
Short header
Usage:
key[/switches] <mandatory args> [optional]
Switches:
switch1 - description
switch2 - description
Examples:
usage example and output
Longer documentation.
"""
```
- `[ ]` for optional args, `< >` for descriptions of what to type, `||` to separate choices
- Commands requiring arguments should return a `Usage:` message when called with no args
### Function/Method Docstrings
Google-style with indented blocks:
```python
def funcname(a, b, d=False, **kwargs):
"""
Brief description.
Args:
a (str): Description over
multiple lines.
b (int or str): Another argument.
d (bool, optional): An optional keyword argument.
Returns:
str: The result.
Raises:
RuntimeException: If there is an error.
Notes:
Additional context.
"""
```

42
.agents/docs/commands.md Normal file
View File

@ -0,0 +1,42 @@
# Development Commands
## Installation
Prefer `uv` over `pip` for faster installs.
```bash
uv pip install -e . # or `make install`
uv pip install -e .[extra] # optional deps (crypto, SSL, Jupyter, scipy, etc.)
```
## Game Lifecycle
```bash
evennia --init mygame # create new game directory
cd mygame && evennia migrate
evennia start / stop / reload
evennia shell # Django-aware Python shell
evennia istart # interactive mode (for debugging with set_trace)
```
## Testing
```bash
make test # full suite
make testp # parallel (4 cores)
make tests=evennia.objects.tests test # specific module
make tests=evennia.commands.tests.test_command test # specific test file
```
## Formatting
```bash
make format # black + isort
make lint # black --check
```
## PR Conventions
- Feature PRs and contribs go against the `develop` branch
- Critical fixes go against `main`
- Keep unrelated changes in separate branches/PRs

View File

@ -0,0 +1,39 @@
# Core Beliefs
Design principles that inform implementation decisions in Evennia. When in doubt, these guide tradeoffs.
## Evennia is a toolkit, not a game
Evennia provides infrastructure (networking, persistence, command routing) without imposing genre, mechanics, or game style. Never add features that assume a specific type of game. Keep the core generic — game-specific systems belong in `contrib/` or downstream game code.
## Think in Python, not SQL
The typeclass system exists so developers work with Python classes, not database schemas. One `ObjectDB` table holds all objects; the `db_typeclass_path` field points to the Python class that gives it behavior. New entity types are created by subclassing in Python, not by adding database tables. Attributes (`db` handler) store arbitrary Python data without schema changes.
## Extend through hooks, not patches
Objects define clear hook methods called at specific lifecycle points (`at_object_creation`, `at_init`, `at_pre_move`, `at_look`, etc.). New behavior goes in hook overrides, not by modifying core internals. This keeps custom code predictable and upgrade-safe.
## Compose, don't branch
CommandSets merge using set operations (union, intersection, difference). Adding a CmdSet and then removing it restores the original state. This allows layering complex states (combat + darkness + status effects) without nested conditionals. Prefer composable, removable components over boolean flags.
## Fail closed
The lock system denies access by default. Everything is inaccessible unless explicitly permitted. When designing access checks, start locked and whitelist — don't start open and blacklist.
## Objects carry their own state
Handlers (Attributes, Tags, Locks, Scripts, Commands) attach directly to objects. State and behavior travel with the object, not in external registries. The idmapper cache ensures you always get the same Python instance for a given database object, so on-object state is reliable.
## Portal and Server are separate concerns
The Portal handles network protocols and stays running across reloads. The Server handles game logic and can be hot-reloaded. Neither knows the other's internals — they communicate via AMP. Don't leak protocol details into game logic or vice versa.
## The framework should be complete
Evennia includes its own web server, webclient, admin interface, and REST API. All connection methods (telnet, websocket, SSH) use the same game objects. Avoid requiring external services for core functionality.
## Keep the schema simple
Complexity grows through Python objects (typeclasses, attributes, tags), not through database tables. The core schema is intentionally minimal and stable. Resist adding new models — use Attributes and Tags on existing models instead when possible.

45
.agents/docs/testing.md Normal file
View File

@ -0,0 +1,45 @@
# Testing
## Running Tests
Tests require a temporary game directory with migrations applied:
```bash
# Full test suite
make test
# Parallel tests (4 cores)
make testp
# Specific module (TESTS variable)
make tests=evennia.objects.tests test
make tests=evennia.commands.tests.test_command test
# Manual equivalent (from inside a game dir)
evennia test --keepdb evennia.objects.tests
```
The Makefile creates a `.test_game_dir/`, runs `evennia migrate`, then `evennia test --keepdb`. Tests use Django's test runner, not pytest.
## Test Base Classes
All in `evennia/utils/test_resources.py`:
- **`BaseEvenniaTest`** — sets up default objects (account, char1, char2, room1, room2, obj1, obj2, exit, script, session) with enforced default settings. Use this for testing Evennia library code.
- **`BaseEvenniaCommandTest`** — extends `BaseEvenniaTest`, adds `.call(CmdClass, input, expected_output)` for testing command execution.
- **`EvenniaTest`** / **`EvenniaCommandTest`** — same but uses game-dir settings/typeclasses (for downstream game tests, not Evennia library tests).
- **`EvenniaTestCase`** — lightweight, no default objects created. Faster for tests that don't need game state.
## Agent Tooling Tests
Tests for `.agents/tools/` use pytest (not Django's runner). Run with:
```bash
uv run pytest .agents/tools/tests/ -v
```
Do not use bare `python -m pytest` — the system Python may not have pytest installed. `uv run` ensures the project venv is used.
## CI Matrix
CI tests against SQLite, MySQL 8.0, and PostgreSQL 14 across Python 3.12/3.13/3.14. Coverage is collected on Python 3.12 + SQLite only.

318
.agents/tools/clean_rot.py Normal file
View File

@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""
Agent context rot checker.
Validates that the agent knowledge base (AGENTS.md + .agents/docs/) stays lean,
cross-linked, and free of drift. Inspired by OpenAI's harness engineering approach:
AGENTS.md is a map (~100 lines), not a 1000-page manual. Detailed docs live in
.agents/docs/ and are only pulled in when needed.
Run from repo root:
python .agents/tools/clean_rot.py
Exit code 0 = clean, 1 = warnings found.
"""
import re
import sys
from difflib import SequenceMatcher
from pathlib import Path
# --- Thresholds ---
MAX_AGENTS_LINES = 40 # AGENTS.md is an index, not a manual
MAX_DOC_LINES = 120 # individual docs shouldn't bloat either
SIMILARITY_THRESHOLD = 0.6 # flag near-duplicate paragraphs between files
MIN_PARAGRAPH_LEN = 80 # ignore short lines for duplication checks
def _default_paths():
"""Return default paths derived from this script's location."""
repo_root = Path(__file__).resolve().parents[2]
return {
"repo_root": repo_root,
"agents_md": repo_root / "AGENTS.md",
"docs_dir": repo_root / ".agents" / "docs",
"src_dir": repo_root / "evennia",
}
def warn(category, msg):
"""Print a categorized warning."""
print(f" [{category}] {msg}")
def check_line_budget(agents_md, docs_dir, repo_root, **_kw):
"""AGENTS.md is the table of contents — keep it short."""
warnings = 0
if agents_md.exists():
lines = agents_md.read_text().splitlines()
count = len(lines)
if count > MAX_AGENTS_LINES:
warn(
"BLOAT",
f"AGENTS.md is {count} lines (budget: {MAX_AGENTS_LINES}). "
f"Move detail into .agents/docs/ and keep AGENTS.md as an index.",
)
warnings += 1
else:
warn("MISSING", "AGENTS.md not found.")
warnings += 1
if docs_dir.exists():
for doc in sorted(docs_dir.glob("*.md")):
lines = doc.read_text().splitlines()
count = len(lines)
if count > MAX_DOC_LINES:
warn(
"BLOAT",
f"{doc.relative_to(repo_root)} is {count} lines "
f"(budget: {MAX_DOC_LINES}). Consider splitting.",
)
warnings += 1
return warnings
def check_broken_links(agents_md, docs_dir, repo_root, **_kw):
"""Verify all markdown links in AGENTS.md and .agents/docs/ point to files that exist."""
warnings = 0
if not agents_md.exists():
return 0
text = agents_md.read_text()
# Match markdown links: [text](path) — skip http(s) URLs
for match in re.finditer(r"\[([^\]]+)\]\(([^)]+)\)", text):
label, target = match.group(1), match.group(2)
if target.startswith(("http://", "https://", "#")):
continue
resolved = repo_root / target
if not resolved.exists():
warn("BROKEN_LINK", f"AGENTS.md links to '{target}' ({label}) — file not found.")
warnings += 1
# Also check links inside .agents/docs/
if docs_dir.exists():
for doc in docs_dir.glob("*.md"):
doc_text = doc.read_text()
for match in re.finditer(r"\[([^\]]+)\]\(([^)]+)\)", doc_text):
label, target = match.group(1), match.group(2)
if target.startswith(("http://", "https://", "#")):
continue
resolved = doc.parent / target
if not resolved.exists():
rel = doc.relative_to(repo_root)
warn("BROKEN_LINK", f"{rel} links to '{target}' ({label}) — file not found.")
warnings += 1
return warnings
def check_orphan_docs(agents_md, docs_dir, repo_root, **_kw):
"""Every file in .agents/docs/ should be referenced from AGENTS.md."""
warnings = 0
if not docs_dir.exists() or not agents_md.exists():
return 0
agents_text = agents_md.read_text()
for doc in sorted(docs_dir.glob("*.md")):
rel_path = str(doc.relative_to(repo_root))
# Check both with and without leading ./
if rel_path not in agents_text and f"./{rel_path}" not in agents_text:
warn(
"ORPHAN",
f"{rel_path} is not referenced from AGENTS.md — "
f"agents won't discover it via progressive disclosure.",
)
warnings += 1
return warnings
def _extract_paragraphs(text):
"""Split text into non-trivial paragraphs for duplication checking."""
paragraphs = []
current = []
for line in text.splitlines():
stripped = line.strip()
if not stripped:
if current:
para = " ".join(current)
if len(para) >= MIN_PARAGRAPH_LEN:
paragraphs.append(para)
current = []
else:
# Skip code blocks and headings
if not stripped.startswith(("```", "#", "- ", "| ")):
current.append(stripped)
if current:
para = " ".join(current)
if len(para) >= MIN_PARAGRAPH_LEN:
paragraphs.append(para)
return paragraphs
def check_duplication(agents_md, docs_dir, repo_root, **_kw):
"""Flag near-duplicate content between AGENTS.md and .agents/docs/ files.
Duplication means AGENTS.md is inlining detail instead of pointing to it.
"""
warnings = 0
if not agents_md.exists() or not docs_dir.exists():
return 0
agents_paragraphs = _extract_paragraphs(agents_md.read_text())
for doc in sorted(docs_dir.glob("*.md")):
doc_paragraphs = _extract_paragraphs(doc.read_text())
rel = doc.relative_to(repo_root)
for ap in agents_paragraphs:
for dp in doc_paragraphs:
ratio = SequenceMatcher(None, ap, dp).ratio()
if ratio >= SIMILARITY_THRESHOLD:
snippet = ap[:80] + "..." if len(ap) > 80 else ap
warn(
"DUPLICATION",
f"AGENTS.md duplicates content from {rel} "
f"({ratio:.0%} similar): \"{snippet}\"",
)
warnings += 1
return warnings
def check_stale_references(agents_md, docs_dir, repo_root, src_dir, **_kw):
"""Check that source paths and Python module paths mentioned in docs still exist.
Only flags references that look like intentional source tree paths (must contain
a slash for file paths, or start with 'evennia.' for dotted module paths). Bare
filenames, CLI tools, branch names, and Python identifiers are ignored.
"""
warnings = 0
all_docs = []
if agents_md.exists():
all_docs.append(agents_md)
if docs_dir.exists():
all_docs.extend(docs_dir.glob("*.md"))
for doc in all_docs:
text = doc.read_text()
rel = doc.relative_to(repo_root)
# Check backtick-quoted paths that contain a slash (real source paths).
# e.g. `commands/default/`, `typeclasses/attributes.py`, `server/conf/settings.py`
# Skips bare filenames like `cmdhandler.py` and tools like `uv`.
for match in re.finditer(
r"`((?:evennia/)?[a-z_]+/[a-z_/]*(?:\.py)?/?)`", text
):
path_ref = match.group(1)
# Search broadly: repo root, src dir, src/contrib,
# game_template (for game-dir paths like server/conf/settings.py)
candidates = [
src_dir / path_ref,
repo_root / path_ref,
src_dir / "contrib" / path_ref,
src_dir / "game_template" / path_ref,
]
if path_ref.startswith("evennia/"):
candidates.append(repo_root / path_ref)
if not any(c.exists() for c in candidates):
warn("STALE_REF", f"{rel} references `{path_ref}` — path not found in source.")
warnings += 1
# Check backtick-quoted Python dotted paths like `evennia.objects.tests`
for match in re.finditer(r"`(evennia\.[a-z_.]+)`", text):
dotted = match.group(1)
parts = dotted.split(".")
as_file = repo_root / Path(*parts).with_suffix(".py")
as_dir = repo_root / Path(*parts)
as_init = as_dir / "__init__.py"
if not (as_file.exists() or as_dir.exists() or as_init.exists()):
warn("STALE_REF", f"{rel} references `{dotted}` — module not found.")
warnings += 1
return warnings
def check_index_density(agents_md, **_kw):
"""AGENTS.md should be mostly pointers, not prose. Check the ratio of
link/reference lines vs content lines."""
warnings = 0
if not agents_md.exists():
return 0
text = agents_md.read_text()
lines = text.splitlines()
non_empty = [l for l in lines if l.strip()]
if not non_empty:
return 0
# Count lines that are structural: headings, links, bullets, code blocks, blank
structural_pattern = re.compile(
r"^\s*$|" # blank
r"^#+\s|" # heading
r"^```|" # code fence
r".*\[.*\]\(.*\)|" # contains a link
r"^\s*[-*]\s|" # bullet point
r"^@" # directive
)
in_code_block = False
structural = 0
for line in lines:
if line.strip().startswith("```"):
in_code_block = not in_code_block
structural += 1
elif in_code_block:
structural += 1 # code block contents are structural
elif structural_pattern.match(line):
structural += 1
prose = len(lines) - structural
# If more than 50% of AGENTS.md is prose, it's becoming a manual
if non_empty and prose / len(non_empty) > 0.50:
warn(
"DENSITY",
f"AGENTS.md is {prose / len(non_empty):.0%} prose — "
f"keep it as an index with pointers. Move prose to .agents/docs/.",
)
warnings += 1
return warnings
ALL_CHECKS = [
("Line budgets", check_line_budget),
("Broken links", check_broken_links),
("Orphan docs", check_orphan_docs),
("Duplication", check_duplication),
("Stale references", check_stale_references),
("Index density", check_index_density),
]
def main():
paths = _default_paths()
print(f"Agent context rot check: {paths['repo_root']}\n")
total = 0
for name, fn in ALL_CHECKS:
print(f"Checking {name}...")
count = fn(**paths)
total += count
if count == 0:
print(" OK")
print()
if total == 0:
print("All clean — agent context is lean.")
return 0
else:
print(f"{total} warning(s) found — review above to keep context rot-free.")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

View File

@ -0,0 +1,309 @@
"""
Tests for .agents/tools/clean_rot.py
Run with:
python -m pytest .agents/tools/tests/ -v
"""
import sys
import textwrap
from pathlib import Path
import pytest
# Make the tools package importable
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from clean_rot import (
_extract_paragraphs,
check_broken_links,
check_duplication,
check_index_density,
check_line_budget,
check_orphan_docs,
check_stale_references,
)
@pytest.fixture
def repo(tmp_path):
"""Scaffold a minimal fake repo for testing."""
agents_md = tmp_path / "AGENTS.md"
docs_dir = tmp_path / ".agents" / "docs"
src_dir = tmp_path / "evennia"
docs_dir.mkdir(parents=True)
src_dir.mkdir()
return {
"repo_root": tmp_path,
"agents_md": agents_md,
"docs_dir": docs_dir,
"src_dir": src_dir,
}
# ---- check_line_budget ----
class TestLineBudget:
def test_clean(self, repo):
repo["agents_md"].write_text("# Index\n\n- [Foo](foo.md)\n")
assert check_line_budget(**repo) == 0
def test_missing_agents_md(self, repo):
assert check_line_budget(**repo) == 1
def test_agents_md_over_budget(self, repo):
repo["agents_md"].write_text("\n".join(f"line {i}" for i in range(50)))
assert check_line_budget(**repo) == 1
def test_agents_md_at_budget(self, repo):
repo["agents_md"].write_text("\n".join(f"line {i}" for i in range(40)))
assert check_line_budget(**repo) == 0
def test_doc_over_budget(self, repo):
repo["agents_md"].write_text("# Index\n")
(repo["docs_dir"] / "big.md").write_text("\n".join(f"line {i}" for i in range(150)))
assert check_line_budget(**repo) == 1
def test_doc_under_budget(self, repo):
repo["agents_md"].write_text("# Index\n")
(repo["docs_dir"] / "small.md").write_text("# Small doc\n\nSome content.\n")
assert check_line_budget(**repo) == 0
# ---- check_broken_links ----
class TestBrokenLinks:
def test_clean(self, repo):
(repo["docs_dir"] / "arch.md").write_text("# Architecture\n")
repo["agents_md"].write_text("See [Arch](.agents/docs/arch.md)\n")
assert check_broken_links(**repo) == 0
def test_broken_link_in_agents(self, repo):
repo["agents_md"].write_text("See [Missing](does/not/exist.md)\n")
assert check_broken_links(**repo) == 1
def test_http_links_skipped(self, repo):
repo["agents_md"].write_text("See [Docs](https://example.com)\n")
assert check_broken_links(**repo) == 0
def test_anchor_links_skipped(self, repo):
repo["agents_md"].write_text("See [Section](#section)\n")
assert check_broken_links(**repo) == 0
def test_broken_link_in_doc(self, repo):
repo["agents_md"].write_text("# Index\n")
(repo["docs_dir"] / "arch.md").write_text("See [Nope](nonexistent.md)\n")
assert check_broken_links(**repo) == 1
def test_valid_link_in_doc(self, repo):
repo["agents_md"].write_text("# Index\n")
(repo["docs_dir"] / "a.md").write_text("See [B](b.md)\n")
(repo["docs_dir"] / "b.md").write_text("# B\n")
assert check_broken_links(**repo) == 0
def test_no_agents_md(self, repo):
assert check_broken_links(**repo) == 0
# ---- check_orphan_docs ----
class TestOrphanDocs:
def test_clean(self, repo):
(repo["docs_dir"] / "arch.md").write_text("# Arch\n")
repo["agents_md"].write_text("See [Arch](.agents/docs/arch.md)\n")
assert check_orphan_docs(**repo) == 0
def test_orphan_detected(self, repo):
(repo["docs_dir"] / "arch.md").write_text("# Arch\n")
(repo["docs_dir"] / "secret.md").write_text("# Hidden\n")
repo["agents_md"].write_text("See [Arch](.agents/docs/arch.md)\n")
assert check_orphan_docs(**repo) == 1
def test_no_docs_dir(self, repo):
import shutil
shutil.rmtree(repo["docs_dir"])
repo["agents_md"].write_text("# Index\n")
assert check_orphan_docs(**repo) == 0
def test_no_agents_md(self, repo):
(repo["docs_dir"] / "arch.md").write_text("# Arch\n")
assert check_orphan_docs(**repo) == 0
# ---- _extract_paragraphs ----
class TestExtractParagraphs:
def test_skips_headings_and_bullets(self):
text = textwrap.dedent("""\
# Heading
- bullet point
This is a real paragraph that is long enough to pass the minimum length threshold for checking.
""")
paras = _extract_paragraphs(text)
assert len(paras) == 1
assert "real paragraph" in paras[0]
def test_skips_code_blocks(self):
text = textwrap.dedent("""\
```bash
make test
```
This is a real paragraph that is long enough to pass the minimum length threshold for checking.
""")
paras = _extract_paragraphs(text)
assert len(paras) == 1
assert "make test" not in paras[0]
def test_skips_short_paragraphs(self):
text = "Short.\n\nAlso short.\n"
assert _extract_paragraphs(text) == []
def test_joins_multiline_paragraph(self):
text = (
"This is the first line of a paragraph that will be joined together "
"with the second line.\n"
"This is the second line of that same paragraph which continues the thought.\n"
)
paras = _extract_paragraphs(text)
assert len(paras) == 1
assert "first line" in paras[0]
assert "second line" in paras[0]
# ---- check_duplication ----
class TestDuplication:
def test_clean_no_overlap(self, repo):
repo["agents_md"].write_text(
"This is unique content in the index file that does not appear anywhere else "
"in the documentation tree at all.\n"
)
(repo["docs_dir"] / "arch.md").write_text(
"This is completely different content about architecture that shares nothing "
"with the index file whatsoever.\n"
)
assert check_duplication(**repo) == 0
def test_duplicate_detected(self, repo):
shared = (
"Tests use Django's test runner not pytest. The Makefile creates a test game dir "
"runs migrations then runs evennia test with keepdb.\n"
)
repo["agents_md"].write_text(shared)
(repo["docs_dir"] / "testing.md").write_text(shared)
assert check_duplication(**repo) >= 1
def test_no_docs_dir(self, repo):
import shutil
shutil.rmtree(repo["docs_dir"])
repo["agents_md"].write_text("# Index\n")
assert check_duplication(**repo) == 0
# ---- check_stale_references ----
class TestStaleReferences:
def test_valid_slash_path(self, repo):
(repo["src_dir"] / "objects").mkdir()
repo["agents_md"].write_text("Models in `objects/models.py` etc.\n")
# create the file so it resolves
(repo["src_dir"] / "objects" / "models.py").write_text("")
assert check_stale_references(**repo) == 0
def test_stale_slash_path(self, repo):
repo["agents_md"].write_text("See `objects/nonexistent/` for details.\n")
assert check_stale_references(**repo) == 1
def test_valid_dotted_module(self, repo):
mod_dir = repo["repo_root"] / "evennia" / "objects"
mod_dir.mkdir(parents=True, exist_ok=True)
(mod_dir / "tests.py").write_text("")
repo["agents_md"].write_text("Run `evennia.objects.tests` for tests.\n")
assert check_stale_references(**repo) == 0
def test_stale_dotted_module(self, repo):
repo["agents_md"].write_text("Run `evennia.nonexistent.module` for details.\n")
assert check_stale_references(**repo) == 1
def test_bare_filenames_ignored(self, repo):
repo["agents_md"].write_text("Edit `cmdhandler.py` and use `uv` to install.\n")
assert check_stale_references(**repo) == 0
def test_contrib_path_resolved(self, repo):
contrib = repo["src_dir"] / "contrib" / "base_systems"
contrib.mkdir(parents=True)
repo["agents_md"].write_text("See `base_systems/` in contrib.\n")
assert check_stale_references(**repo) == 0
def test_game_template_path_resolved(self, repo):
tmpl = repo["src_dir"] / "game_template" / "server" / "conf"
tmpl.mkdir(parents=True)
(tmpl / "settings.py").write_text("")
repo["agents_md"].write_text("Games override in `server/conf/settings.py`.\n")
assert check_stale_references(**repo) == 0
def test_no_agents_md(self, repo):
assert check_stale_references(**repo) == 0
def test_checks_docs_dir_files_too(self, repo):
repo["agents_md"].write_text("# Index\n")
(repo["docs_dir"] / "arch.md").write_text("See `gone/deleted/` for info.\n")
assert check_stale_references(**repo) == 1
# ---- check_index_density ----
class TestIndexDensity:
def test_mostly_structural(self, repo):
repo["agents_md"].write_text(textwrap.dedent("""\
# AGENTS.md
## Section
- [Link](foo.md)
- [Link](bar.md)
```bash
make test
```
"""))
assert check_index_density(**repo) == 0
def test_too_much_prose(self, repo):
# All non-structural lines
prose_lines = [f"This is prose line number {i}." for i in range(30)]
repo["agents_md"].write_text("\n".join(prose_lines) + "\n")
assert check_index_density(**repo) == 1
def test_no_agents_md(self, repo):
assert check_index_density(**repo) == 0
def test_empty_file(self, repo):
repo["agents_md"].write_text("")
assert check_index_density(**repo) == 0
def test_code_block_contents_are_structural(self, repo):
repo["agents_md"].write_text(textwrap.dedent("""\
# AGENTS.md
```bash
make test
make format
uv pip install -e .
```
One prose line here.
"""))
assert check_index_density(**repo) == 0

35
AGENTS.md Normal file
View File

@ -0,0 +1,35 @@
# AGENTS.md
This file provides guidance to AI coding agents working in this repository.
Evennia is a Python (>= 3.12) framework for building text-based multiplayer online games (MUD/MUX/MUSH/MOO). It is a library, not a game.
## Quick Reference
Use `uv run` to execute commands in the project venv. Prefer `uv` over `pip`.
```bash
make test # full test suite (Evennia/Django)
make tests=evennia.objects.tests test # specific module
make format # black + isort
make lint # black --check
make cleanrot # check agent context for rot
uv pip install -e . # dev install
uv run pytest .agents/tools/tests/ -v # agent tooling tests (pytest)
```
## Key Rules
- **TDD**: write tests first. Prefer no-DB tests > mocks > full DB-backed tests.
- **Don't manually format code.** Run `make format` after editing.
- **All code must have Google-style docstrings.** See [Code Style](.agents/docs/code-style.md).
- After editing agent context files, run `make cleanrot`.
## Docs
- [Core Beliefs](.agents/docs/core-beliefs.md) — design principles that guide tradeoffs (toolkit not game, think in Python not SQL, hooks not patches, compose don't branch)
- [Architecture](.agents/docs/architecture.md) — two-process model, typeclass system, command system, subsystems, flat API, settings
- [Testing](.agents/docs/testing.md) — running tests, test base classes, DB setup, CI matrix
- [Code Style](.agents/docs/code-style.md) — docstring conventions, command docstring format
- [Development Commands](.agents/docs/commands.md) — install, game lifecycle, test/format commands, PR conventions
- [CI/CD](.agents/docs/ci.md) — GitHub Actions workflows, test matrix, database configs, Docker, secrets

View File

@ -2,6 +2,8 @@
## Main branch ## Main branch
Feat: Add AGENTS.md and .agents context files to aid AI agent development (Griatch)
Feat: Add `uv.lock` for Evennia library developers wanting to use the `uv` tool (Griatch)
Fix: Improve indentation/formatting for east-asian languages (Griatch, with inspiration from hhsiao) Fix: Improve indentation/formatting for east-asian languages (Griatch, with inspiration from hhsiao)
Docs: Griatch Docs: Griatch

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

View File

@ -13,7 +13,8 @@ default:
@echo " make test - run evennia test suite with all default values." @echo " make test - run evennia test suite with all default values."
@echo " make tests=evennia.path test - run only specific test or tests." @echo " make tests=evennia.path test - run only specific test or tests."
@echo " make testp - run test suite using multiple cores." @echo " make testp - run test suite using multiple cores."
@echo " make release - publish evennia to pypi (requires pypi credentials) @echo " make release - publish evennia to pypi (requires pypi credentials)"
@echo " make cleanrot - check agent context docs for rot/bloat"
install: install:
pip install -e . pip install -e .
@ -49,3 +50,6 @@ version:
release: release:
./.release.sh ./.release.sh
cleanrot:
python .agents/tools/clean_rot.py

2438
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff