Compare commits
6 commits
708985e62a
...
dde166f89c
| Author | SHA1 | Date | |
|---|---|---|---|
| dde166f89c | |||
| 9eaca966c8 | |||
| fea7430304 | |||
| 1064af87d0 | |||
| 05afff9538 | |||
| a5588ca21b |
6 changed files with 865 additions and 1 deletions
408
docs/builder-manual.md
Normal file
408
docs/builder-manual.md
Normal 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)
|
||||
```
|
||||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
332
tests/test_admin.py
Normal 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
|
||||
Loading…
Reference in a new issue