Compare commits

..

6 commits

6 changed files with 865 additions and 1 deletions

408
docs/builder-manual.md Normal file
View file

@ -0,0 +1,408 @@
# Builder Manual
This is your reference for creating content in the MUD. Covers getting around, building zones, placing things, combat moves, mobs, crafting recipes. Keep this open while building - it's meant to be practical and scannable.
## Getting Around
- Movement: `n`, `s`, `e`, `w`, `ne`, `nw`, `se`, `sw` (or long forms like `north`)
- `look` or `l` - see the room (ASCII viewport with `@` as you, `*` as others)
- `home` - teleport to your personal home zone (created on first use, 9x9 grid)
- `home return` - go back to where you were before `home`
- `@goto <zone>` - (admin) teleport to any zone by name. Goes to spawn point. Examples: `@goto hub`, `@goto tavern`, `@goto overworld`
- Portals: step onto a portal tile to auto-teleport to another zone. Defined in zone TOML files.
Existing zones: overworld (1000x1000, toroidal, procedural terrain), hub, tavern, treehouse, flower
## Your Home Zone
- `home` creates a 9x9 zone with `#` walls and `.` grass on first use
- Saved to `data/player_zones/<name>.toml`
- Marked safe (no combat)
- Commands that only work in YOUR home zone:
- `terrain <tile>` - paint the tile under your feet. Can't edit border walls. Common tiles: `.` grass, `~` water, `^` mountain, `T` tree, `,` dirt, `"` tall grass, `+` path
- `describe <text>` - set zone description (max 500 chars). Bare `describe` shows current.
- `furnish <item>` - place an item from your inventory as furniture at your position
- `unfurnish <item>` - pick furniture back up into inventory
## Building Zones (Admin)
### Creating a Zone
- `@dig <name> <width> <height>` - creates a blank zone and teleports you there
- The zone starts as all `.` (grass) with `#` borders
- You're placed at (0,0)
### Painting Terrain
- `@paint` - toggle paint mode
- `brush <char>` - set what tile you're painting (e.g., `brush ~` for water)
- `p` - toggle between painting mode and survey mode
- In painting mode, every tile you walk over gets painted with your brush
- In survey mode, you move without painting (to get to a new area)
### Placing Objects
- `@place <thing>` - place a thing template at your current position
- Available thing templates: chair, bookshelf, chest, fountain, lamp, painting, rug, table, rock, sack, plank, nail
### Saving
- `@save` - saves current zone to `content/zones/<name>.toml`
- Home zones auto-save on furnish/unfurnish/terrain/describe
### Zone TOML Format
Here's the full format using the tavern as an example:
```toml
name = "tavern"
description = "a cozy tavern with a crackling fireplace"
width = 8
height = 6
toroidal = false
safe = true
spawn_x = 1
spawn_y = 1
[terrain]
rows = [
"########",
"#......#",
"#......#",
"#......#",
"#......#",
"####.###",
]
[terrain.impassable]
tiles = ["#"]
[[portals]]
x = 4
y = 5
target = "hub:14,7"
label = "the tavern door"
[ambient]
interval = 60
messages = [
"the fire crackles and pops in the hearth",
"a draft of cold air blows through the room",
"you hear muffled conversation from patrons in the corner",
"the smell of roasting meat and ale fills the air",
]
```
**Field Reference:**
- `name` - unique identifier, used by @goto and portal targets
- `description` - shown in the Where: header when you look
- `width`/`height` - grid dimensions
- `toroidal` - true = wraps at edges (overworld does this), false = walls at edges
- `safe` - true = no combat allowed
- `spawn_x`/`spawn_y` - where players land when teleporting in
- `[terrain]` - `rows` is a list of strings, each string is one row. Each character is a tile.
- `[terrain.impassable]` - `tiles` lists which characters block movement
- `[[portals]]` - each portal has x, y position, target as "zone_name:x,y", and a label
- `[ambient]` - interval (seconds) and messages (random pick) for atmospheric text
- Can also have `[[spawn_rules]]` for mobs (see Mobs section)
### Connecting Zones
Portals are the main way to connect zones. To make a two-way connection:
1. Add a portal in zone A pointing to zone B
2. Add a portal in zone B pointing back to zone A
3. Example: hub has `target = "tavern:4,5"` and tavern has `target = "hub:14,7"`
Portal target format: `"zone_name:x,y"` where x,y is the destination tile.
## Things (Items & Furniture)
### Existing Templates
chair, bookshelf, chest, fountain, lamp, painting, rug, table, rock, sack, plank, nail
### Creating New Templates
Add a `.toml` file in `content/things/`:
**Simple item:**
```toml
name = "chair"
description = "a simple wooden chair with a woven seat"
portable = true
```
**Non-portable (furniture/fixture):**
```toml
name = "fountain"
description = "a weathered stone fountain, water trickling into a mossy basin"
portable = false
```
**Container:**
```toml
name = "chest"
description = "a sturdy wooden chest with iron bindings"
portable = false
capacity = 5
closed = true
locked = false
aliases = ["box"]
```
**Fields:**
- `name` - identifier and display name
- `description` - what `look <thing>` shows
- `portable` - can it be picked up? (true/false)
- `aliases` - alternative names for targeting (optional)
- `capacity` - makes it a container with N slots (optional)
- `closed` / `locked` - container state (optional)
- `readable_text` - text shown by `read <thing>` (optional)
- `tags` - arbitrary tags list (optional)
### Item Commands
- `get <item>` / `take <item>` - pick up from ground
- `drop <item>` - drop to ground
- `inventory` / `i` - list what you're carrying
- `get <item> from <container>` - take from container
- `put <item> in <container>` - put into container
- `open <container>` / `close <container>`
## Mobs
### Existing Templates
- training_dummy - PL 200, stamina 100, no moves (punching bag)
- goblin - PL 50, stamina 40, knows punch and sweep, drops crude club and gold
- librarian - PL 50, stamina 50, NPC with schedule and dialogue
### Spawning
- `spawn <mob>` - spawn a mob at your position (e.g., `spawn goblin`)
### Creating New Mob Templates
Add a `.toml` file in `content/mobs/`:
```toml
name = "goblin"
description = "a snarling goblin with a crude club"
pl = 50.0
stamina = 40.0
max_stamina = 40.0
moves = ["punch left", "punch right", "sweep"]
[[loot]]
name = "crude club"
chance = 0.8
description = "a crude wooden club"
[[loot]]
name = "gold coin"
chance = 0.5
min_count = 1
max_count = 3
```
**Fields:**
- `name` / `description` - identity
- `pl` - power level (health + damage scaling)
- `stamina` / `max_stamina` - fuel for moves
- `moves` - list of combat moves it can use (must match move names including variant, e.g., "punch left")
- `[[loot]]` - each entry: `name`, `chance` (0.0-1.0), optional `description`, `min_count`, `max_count`
### Zone Spawn Rules
Add to a zone TOML to auto-spawn mobs:
```toml
[[spawn_rules]]
template = "goblin"
max_count = 3
region = { x_min = 2, x_max = 12, y_min = 2, y_max = 12 }
```
## Combat Moves
### Existing Moves
Attacks: punch (left/right), roundhouse, sweep
Defenses: dodge (left/right), parry, duck, jump
### Creating New Moves
Add a `.toml` file in `content/combat/`:
**Attack with variants:**
```toml
name = "punch"
description = "a close-range strike with the fist, quick but predictable"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 1800
damage_pct = 0.15
[variants.left]
telegraph = "{attacker} retracts {his} left arm..."
announce = "{attacker} throw{s} a left hook at {defender}!"
resolve_hit = "{attacker} connect{s} with a left hook!"
resolve_miss = "{defender} dodge{s} {attacker}'s left hook!"
countered_by = ["dodge right", "parry high"]
[variants.right]
telegraph = "{attacker} retracts {his} right arm..."
announce = "{attacker} throw{s} a right hook at {defender}!"
resolve_hit = "{attacker} connect{s} with a right hook!"
resolve_miss = "{defender} dodge{s} {attacker}'s right hook!"
countered_by = ["dodge left", "parry high"]
```
**Simple defense:**
```toml
name = "dodge"
description = "a quick sidestep to evade incoming attacks"
move_type = "defense"
stamina_cost = 3.0
timing_window_ms = 800
[variants.left]
[variants.right]
```
**Fields:**
- `name` - command name (what the player types)
- `description` - shown in help/skills
- `move_type` - "attack" or "defense"
- `stamina_cost` - stamina consumed per use
- `timing_window_ms` - how long the window is open (attacks: time to defend, defenses: commitment time)
- `damage_pct` - fraction of attacker's PL dealt as damage (attacks only)
- `[variants.X]` - each variant becomes a separate command: "punch left", "punch right"
- POV templates: `{attacker}`, `{defender}`, `{s}` (third person s), `{es}`, `{his}`, `{him}`, `{y|ies}` (irregular conjugation)
- `countered_by` - list of defense moves that counter this attack variant
- Unlock conditions (optional): `unlock_type = "kill_count"`, `unlock_value = 10` (need 10 kills to learn)
### Hot Reloading
- `reload <name>` - reload a combat move or command TOML without restarting the server
- Example: `reload punch` after editing punch.toml
## Crafting Recipes
### Existing Recipes
- wooden_table: 3 planks + 2 nails = table
### Creating New Recipes
Add a `.toml` file in `content/recipes/`:
```toml
name = "wooden_table"
description = "Craft a sturdy table from planks and nails"
ingredients = ["plank", "plank", "plank", "nail", "nail"]
result = "table"
```
**Fields:**
- `name` - recipe identifier
- `description` - shown in `recipes <name>`
- `ingredients` - list of thing template names (duplicates = need multiple)
- `result` - thing template name to spawn
### Commands
- `recipes` - list all recipes
- `recipes <name>` - show ingredients and result
- `craft <name>` - craft it (consumes ingredients from inventory, spawns result)
## Other Useful Commands
### Stats & Info
- `score` / `stats` / `profile` - character sheet (PL, stamina, K/D, time played, unlocked moves)
- `skills` - list combat moves (shows locked/unlocked)
- `commands` / `cmds` - list all available commands
- `help <command>` - detailed info on any command or move
- `client` - show terminal/protocol capabilities
### Power System
- `power up` - spend stamina to raise PL toward max (tick-based, visible aura)
- `power down` - drop PL to minimum (hide from scouters)
- `power <number>` - set PL to exact value
- `power stop` - cancel ongoing power-up
### Recovery
- `rest` - toggle resting (faster stamina recovery, can still see room)
- `sleep` / `wake` - toggle sleeping (fastest recovery, blind to room events)
### Social
- `talk <npc>` - start conversation with an NPC
- `reply <number>` - choose a dialogue option
### Other
- `fly` - toggle flying
- `alias <name> <command>` - create shortcut (e.g., `alias pr punch right`)
- `unalias <name>` - remove alias
- `play <story>` - play interactive fiction
- `edit` - in-world text editor
- `read <thing>` - read a readable object
- `quit` / `q` - leave the game
## Quick Reference
| What | Command |
|------|---------|
| Move | `n` `s` `e` `w` `ne` `nw` `se` `sw` |
| Look | `look` or `l` |
| Go home | `home` / `home return` |
| Teleport (admin) | `@goto <zone>` |
| Create zone (admin) | `@dig <name> <w> <h>` |
| Paint mode (admin) | `@paint`, `brush <char>`, `p` to toggle |
| Save zone (admin) | `@save` |
| Place thing (admin) | `@place <thing>` |
| Home terrain | `terrain <tile>` |
| Home description | `describe <text>` |
| Furnish/unfurnish | `furnish <item>` / `unfurnish <item>` |
| Spawn mob | `spawn <mob>` |
| Inventory | `i` or `inventory` |
| Pick up / drop | `get <item>` / `drop <item>` |
| Craft | `craft <recipe>` |
| Recipes | `recipes` / `recipes <name>` |
| Score | `score` or `stats` |
| Skills | `skills` |
| Help | `help <command>` |
| Power | `power up/down/stop/<number>` |
| Rest / Sleep | `rest` / `sleep` / `wake` |
| Attack | `punch left <target>` |
| Defend | `dodge left` |
| Reload content | `reload <name>` |
| Aliases | `alias <name> <cmd>` / `unalias <name>` |
## Content File Locations
```
content/
combat/ - attack and defense move definitions
commands/ - TOML command metadata
mobs/ - mob templates
recipes/ - crafting recipes
things/ - item and furniture templates
zones/ - zone definitions
dialogue/ - NPC dialogue trees
data/
player_zones/ - saved home zones (auto-generated)
worlds/
earth/ - world config (seed, dimensions)
```

View file

@ -4,7 +4,8 @@ from pathlib import Path
from mudlib.commands import CommandDefinition, register
from mudlib.export import export_zone_to_file
from mudlib.player import Player
from mudlib.player import Player, players
from mudlib.store import account_exists, load_player_data, set_admin
from mudlib.things import spawn_thing, thing_templates
from mudlib.zone import Zone
from mudlib.zones import get_zone, register_zone
@ -114,7 +115,78 @@ async def cmd_place(player: Player, args: str) -> None:
await player.send(f"Placed {thing_name} at ({player.x}, {player.y}).\r\n")
async def cmd_promote(player: Player, args: str) -> None:
"""Grant admin status to a player."""
target_name = args.strip()
if not target_name:
await player.send("Usage: @promote <player_name>\r\n")
return
if not account_exists(target_name):
await player.send(f"Account '{target_name}' not found.\r\n")
return
# Check current status via live player or DB
data = load_player_data(target_name)
if data and data.get("is_admin"):
await player.send(f"{target_name} is already an admin.\r\n")
return
set_admin(target_name, True)
# Update live player if online
target = next(
(p for p in players.values() if p.name.lower() == target_name.lower()),
None,
)
if target is not None:
target.is_admin = True
await target.send("You have been granted admin status.\r\n")
await player.send(f"{target_name} is now an admin.\r\n")
async def cmd_demote(player: Player, args: str) -> None:
"""Revoke admin status from a player."""
target_name = args.strip()
if not target_name:
await player.send("Usage: @demote <player_name>\r\n")
return
if target_name.lower() == player.name.lower():
await player.send("You can't demote yourself.\r\n")
return
if not account_exists(target_name):
await player.send(f"Account '{target_name}' not found.\r\n")
return
data = load_player_data(target_name)
if data and not data.get("is_admin"):
await player.send(f"{target_name} is not an admin.\r\n")
return
set_admin(target_name, False)
# Update live player if online
target = next(
(p for p in players.values() if p.name.lower() == target_name.lower()),
None,
)
if target is not None:
target.is_admin = False
await target.send("Your admin status has been revoked.\r\n")
await player.send(f"{target_name} is no longer an admin.\r\n")
register(CommandDefinition("@goto", cmd_goto, admin=True, help="Teleport to a zone"))
register(CommandDefinition("@dig", cmd_dig, admin=True, help="Create a new zone"))
register(CommandDefinition("@save", cmd_save, admin=True, help="Save current zone"))
register(CommandDefinition("@place", cmd_place, admin=True, help="Place a thing"))
register(
CommandDefinition("@promote", cmd_promote, admin=True, help="Grant admin status")
)
register(
CommandDefinition("@demote", cmd_demote, admin=True, help="Revoke admin status")
)

View file

@ -229,6 +229,9 @@ async def cmd_commands(player: Player, args: str) -> None:
other: list[CommandDefinition] = []
for defn in unique_commands:
# Hide admin commands from non-admins
if defn.admin and not player.is_admin:
continue
# Check if it's a movement command
if defn.name in DIRECTIONS:
movement.append(defn)

View file

@ -15,6 +15,7 @@ from telnetlib3.server_shell import readline2
import mudlib.combat.commands
import mudlib.commands
import mudlib.commands.build
import mudlib.commands.containers
import mudlib.commands.crafting
import mudlib.commands.describe
@ -26,6 +27,7 @@ import mudlib.commands.help
import mudlib.commands.home
import mudlib.commands.look
import mudlib.commands.movement
import mudlib.commands.paint
import mudlib.commands.play
import mudlib.commands.portals
import mudlib.commands.power
@ -344,6 +346,7 @@ async def shell(
"inventory": [],
"description": "",
"home_zone": None,
"is_admin": False,
}
# Resolve zone from zone_name using zone registry
@ -388,6 +391,7 @@ async def shell(
# Set description and home zone
player.description = player_data.get("description", "")
player.home_zone = player_data.get("home_zone")
player.is_admin = player_data.get("is_admin", False)
# Load aliases from database
player.aliases = load_aliases(player_name)

View file

@ -25,6 +25,7 @@ class PlayerData(TypedDict):
inventory: list[str]
description: str
home_zone: str | None
is_admin: bool
class StatsData(TypedDict):
@ -112,6 +113,15 @@ def init_db(db_path: str | Path) -> None:
)
if "home_zone" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN home_zone TEXT")
if "is_admin" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0"
)
# First account ever created becomes admin
cursor.execute(
"UPDATE accounts SET is_admin = 1 "
"WHERE rowid = (SELECT rowid FROM accounts ORDER BY created_at ASC LIMIT 1)"
)
conn.commit()
conn.close()
@ -171,6 +181,10 @@ def create_account(name: str, password: str) -> bool:
"INSERT INTO accounts (name, password_hash, salt) VALUES (?, ?, ?)",
(name, password_hash, salt.hex()),
)
# First account ever created becomes admin
cursor.execute("SELECT COUNT(*) FROM accounts")
if cursor.fetchone()[0] == 1:
cursor.execute("UPDATE accounts SET is_admin = 1 WHERE name = ?", (name,))
conn.commit()
return True
except sqlite3.IntegrityError:
@ -329,6 +343,7 @@ def load_player_data(name: str) -> PlayerData | None:
has_inventory = "inventory" in columns
has_description = "description" in columns
has_home_zone = "home_zone" in columns
has_is_admin = "is_admin" in columns
# Build SELECT based on available columns
select_cols = "x, y, pl, stamina, max_stamina, flying"
@ -340,6 +355,8 @@ def load_player_data(name: str) -> PlayerData | None:
select_cols += ", description"
if has_home_zone:
select_cols += ", home_zone"
if has_is_admin:
select_cols += ", is_admin"
cursor.execute(
f"SELECT {select_cols} FROM accounts WHERE name = ?",
@ -376,6 +393,11 @@ def load_player_data(name: str) -> PlayerData | None:
home_zone = result[idx]
idx += 1
is_admin = False
if has_is_admin:
is_admin = bool(result[idx])
idx += 1
return {
"x": x,
"y": y,
@ -387,6 +409,7 @@ def load_player_data(name: str) -> PlayerData | None:
"inventory": inventory,
"description": description,
"home_zone": home_zone,
"is_admin": is_admin,
}
@ -407,6 +430,28 @@ def update_last_login(name: str) -> None:
conn.close()
def set_admin(name: str, is_admin: bool) -> bool:
"""Set admin status for an account.
Args:
name: Account name (case-insensitive)
is_admin: Whether the account should be admin
Returns:
True if account was found and updated, False if not found
"""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE accounts SET is_admin = ? WHERE name = ?",
(1 if is_admin else 0, name),
)
updated = cursor.rowcount > 0
conn.commit()
conn.close()
return updated
def save_aliases(
name: str, aliases: dict[str, str], db_path: str | Path | None = None
) -> None:

332
tests/test_admin.py Normal file
View file

@ -0,0 +1,332 @@
"""Tests for the admin system."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.build import cmd_demote, cmd_promote
from mudlib.player import Player, players
from mudlib.store import (
create_account,
init_db,
load_player_data,
set_admin,
)
from mudlib.zone import Zone
from mudlib.zones import register_zone, zone_registry
@pytest.fixture(autouse=True)
def clear_state():
players.clear()
zone_registry.clear()
yield
players.clear()
zone_registry.clear()
@pytest.fixture
def db(tmp_path):
init_db(tmp_path / "test.db")
@pytest.fixture
def zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
z = Zone(name="testzone", width=10, height=10, terrain=terrain)
register_zone("testzone", z)
return z
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
def make_player(name, zone, mock_writer, mock_reader, is_admin=False):
p = Player(
name=name,
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
is_admin=is_admin,
)
zone._contents.append(p)
players[name] = p
return p
# Store layer tests
@pytest.mark.asyncio
async def test_first_account_is_admin(db):
"""First account created becomes admin."""
create_account("first", "password")
data = load_player_data("first")
assert data is not None
assert data["is_admin"] is True
@pytest.mark.asyncio
async def test_second_account_not_admin(db):
"""Second account created is not admin."""
create_account("first", "password")
create_account("second", "password")
first_data = load_player_data("first")
second_data = load_player_data("second")
assert first_data is not None
assert first_data["is_admin"] is True
assert second_data is not None
assert second_data["is_admin"] is False
@pytest.mark.asyncio
async def test_set_admin(db):
"""set_admin grants admin status to non-admin account."""
create_account("first", "password")
create_account("second", "password")
# Second account should not be admin initially
data = load_player_data("second")
assert data is not None
assert data["is_admin"] is False
# Promote second account
result = set_admin("second", True)
assert result is True
# Verify admin status
data = load_player_data("second")
assert data is not None
assert data["is_admin"] is True
@pytest.mark.asyncio
async def test_set_admin_nonexistent(db):
"""set_admin returns False for nonexistent account."""
result = set_admin("nobody", True)
assert result is False
@pytest.mark.asyncio
async def test_migration_promotes_earliest(db):
"""Migration promotes earliest account when is_admin column added."""
# Create two accounts
create_account("alice", "password")
create_account("bob", "password")
# Verify first account is admin
alice_data = load_player_data("alice")
bob_data = load_player_data("bob")
assert alice_data is not None
assert alice_data["is_admin"] is True
assert bob_data is not None
assert bob_data["is_admin"] is False
# Command tests
@pytest.mark.asyncio
async def test_promote_no_args(zone, mock_writer, mock_reader, db):
"""promote with no args shows usage."""
player = make_player("builder", zone, mock_writer, mock_reader, is_admin=True)
create_account("builder", "password")
await cmd_promote(player, "")
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "Usage:" in output
@pytest.mark.asyncio
async def test_promote_nonexistent(zone, mock_writer, mock_reader, db):
"""promote nonexistent account shows error."""
player = make_player("builder", zone, mock_writer, mock_reader, is_admin=True)
create_account("builder", "password")
await cmd_promote(player, "nobody")
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "not found" in output
@pytest.mark.asyncio
async def test_promote_offline_player(zone, mock_writer, mock_reader, db):
"""promote offline player updates database."""
player = make_player("builder", zone, mock_writer, mock_reader, is_admin=True)
create_account("builder", "password")
create_account("target", "password")
await cmd_promote(player, "target")
# Verify database updated
data = load_player_data("target")
assert data is not None
assert data["is_admin"] is True
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "is now an admin" in output
@pytest.mark.asyncio
async def test_promote_online_player(zone, mock_writer, mock_reader, db):
"""promote online player updates both database and live state."""
player = make_player("builder", zone, mock_writer, mock_reader, is_admin=True)
create_account("builder", "password")
create_account("target", "password")
# Create target as online player with separate mock_writer
target_writer = MagicMock()
target_writer.write = MagicMock()
target_writer.drain = AsyncMock()
target = make_player("target", zone, target_writer, mock_reader, is_admin=False)
await cmd_promote(player, "target")
# Verify live state updated
assert target.is_admin is True
# Verify database updated
data = load_player_data("target")
assert data is not None
assert data["is_admin"] is True
# Verify messages sent
builder_output = "".join(
call.args[0] for call in player.writer.write.call_args_list
)
assert "is now an admin" in builder_output
target_output = "".join(call.args[0] for call in target_writer.write.call_args_list)
assert "granted admin status" in target_output
@pytest.mark.asyncio
async def test_demote_self(zone, mock_writer, mock_reader, db):
"""demote self shows error."""
player = make_player("builder", zone, mock_writer, mock_reader, is_admin=True)
create_account("builder", "password")
await cmd_demote(player, "builder")
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "can't demote yourself" in output
@pytest.mark.asyncio
async def test_demote_online_player(zone, mock_writer, mock_reader, db):
"""demote online player updates both database and live state."""
player = make_player("builder", zone, mock_writer, mock_reader, is_admin=True)
create_account("builder", "password")
create_account("target", "password")
set_admin("target", True)
# Create target as online admin player with separate mock_writer
target_writer = MagicMock()
target_writer.write = MagicMock()
target_writer.drain = AsyncMock()
target = make_player("target", zone, target_writer, mock_reader, is_admin=True)
await cmd_demote(player, "target")
# Verify live state updated
assert target.is_admin is False
# Verify database updated
data = load_player_data("target")
assert data is not None
assert data["is_admin"] is False
# Verify messages sent
builder_output = "".join(
call.args[0] for call in player.writer.write.call_args_list
)
assert "no longer an admin" in builder_output
target_output = "".join(call.args[0] for call in target_writer.write.call_args_list)
assert "revoked" in target_output
@pytest.mark.asyncio
async def test_demote_no_args(zone, mock_writer, mock_reader, db):
"""demote with no args shows usage."""
player = make_player("builder", zone, mock_writer, mock_reader, is_admin=True)
create_account("builder", "password")
await cmd_demote(player, "")
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "Usage:" in output
@pytest.mark.asyncio
async def test_demote_offline_player(zone, mock_writer, mock_reader, db):
"""demote offline player updates database."""
admin_player = make_player("admin", zone, mock_writer, mock_reader, is_admin=True)
create_account("admin", "password")
create_account("target", "password")
set_admin("target", True)
await cmd_demote(admin_player, "target")
# Verify database updated
data = load_player_data("target")
assert data is not None
assert data["is_admin"] is False
output = "".join(call.args[0] for call in admin_player.writer.write.call_args_list)
assert "no longer an admin" in output
@pytest.mark.asyncio
async def test_demote_nonexistent(zone, mock_writer, mock_reader, db):
"""demote nonexistent account shows error."""
player = make_player("builder", zone, mock_writer, mock_reader, is_admin=True)
create_account("builder", "password")
await cmd_demote(player, "nobody")
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "not found" in output
@pytest.mark.asyncio
async def test_promote_already_admin(zone, mock_writer, mock_reader, db):
"""promote already-admin account shows message."""
player = make_player("builder", zone, mock_writer, mock_reader, is_admin=True)
create_account("builder", "password")
create_account("target", "password")
set_admin("target", True)
await cmd_promote(player, "target")
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "already an admin" in output
@pytest.mark.asyncio
async def test_demote_not_admin(zone, mock_writer, mock_reader, db):
"""demote non-admin account shows message."""
player = make_player("builder", zone, mock_writer, mock_reader, is_admin=True)
create_account("builder", "password")
create_account("target", "password")
await cmd_demote(player, "target")
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "not an admin" in output