Compare commits
8 commits
957a411601
...
05a739da74
| Author | SHA1 | Date | |
|---|---|---|---|
| 05a739da74 | |||
| 8acfa5ea22 | |||
| 6081c90ad1 | |||
| c43b3346ae | |||
| 2e79255aec | |||
| e96fd50de5 | |||
| 7c12bf3318 | |||
| 9437728435 |
16 changed files with 1199 additions and 24 deletions
3
content/things/fountain.toml
Normal file
3
content/things/fountain.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
name = "fountain"
|
||||||
|
description = "a weathered stone fountain, water trickling into a mossy basin"
|
||||||
|
portable = false
|
||||||
4
content/things/rock.toml
Normal file
4
content/things/rock.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name = "rock"
|
||||||
|
description = "a smooth grey rock, worn by wind and rain"
|
||||||
|
portable = true
|
||||||
|
aliases = ["stone"]
|
||||||
|
|
@ -5,6 +5,7 @@ from mudlib.effects import get_effects_at
|
||||||
from mudlib.entity import Entity
|
from mudlib.entity import Entity
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
from mudlib.render.ansi import RESET, colorize_terrain
|
from mudlib.render.ansi import RESET, colorize_terrain
|
||||||
|
from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
# Viewport dimensions
|
# Viewport dimensions
|
||||||
|
|
@ -99,6 +100,15 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
|
|
||||||
# Send to player
|
# Send to player
|
||||||
player.writer.write("\r\n".join(output_lines) + "\r\n")
|
player.writer.write("\r\n".join(output_lines) + "\r\n")
|
||||||
|
|
||||||
|
# Show items on the ground at player's position
|
||||||
|
ground_items = [
|
||||||
|
obj for obj in zone.contents_at(player.x, player.y) if isinstance(obj, Thing)
|
||||||
|
]
|
||||||
|
if ground_items:
|
||||||
|
names = ", ".join(item.name for item in ground_items)
|
||||||
|
player.writer.write(f"On the ground: {names}\r\n")
|
||||||
|
|
||||||
await player.writer.drain()
|
await player.writer.drain()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
96
src/mudlib/commands/things.py
Normal file
96
src/mudlib/commands/things.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Get, drop, and inventory commands for items."""
|
||||||
|
|
||||||
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
def _find_thing_at(name: str, zone: Zone, x: int, y: int) -> Thing | None:
|
||||||
|
"""Find a thing on the ground matching name or alias."""
|
||||||
|
name_lower = name.lower()
|
||||||
|
for obj in zone.contents_at(x, y):
|
||||||
|
if not isinstance(obj, Thing):
|
||||||
|
continue
|
||||||
|
if obj.name.lower() == name_lower:
|
||||||
|
return obj
|
||||||
|
if name_lower in (a.lower() for a in obj.aliases):
|
||||||
|
return obj
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_thing_in_inventory(name: str, player: Player) -> Thing | None:
|
||||||
|
"""Find a thing in the player's inventory matching name or alias."""
|
||||||
|
name_lower = name.lower()
|
||||||
|
for obj in player.contents:
|
||||||
|
if not isinstance(obj, Thing):
|
||||||
|
continue
|
||||||
|
if obj.name.lower() == name_lower:
|
||||||
|
return obj
|
||||||
|
if name_lower in (a.lower() for a in obj.aliases):
|
||||||
|
return obj
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_get(player: Player, args: str) -> None:
|
||||||
|
"""Pick up an item from the ground."""
|
||||||
|
if not args.strip():
|
||||||
|
await player.send("Get what?\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
zone = player.location
|
||||||
|
if zone is None or not isinstance(zone, Zone):
|
||||||
|
await player.send("You are nowhere.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
thing = _find_thing_at(args.strip(), zone, player.x, player.y)
|
||||||
|
if thing is None:
|
||||||
|
await player.send(f"You don't see '{args.strip()}' here.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not player.can_accept(thing):
|
||||||
|
await player.send("You can't pick that up.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
thing.move_to(player)
|
||||||
|
await player.send(f"You pick up {thing.name}.\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_drop(player: Player, args: str) -> None:
|
||||||
|
"""Drop an item from inventory onto the ground."""
|
||||||
|
if not args.strip():
|
||||||
|
await player.send("Drop what?\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
zone = player.location
|
||||||
|
if zone is None or not isinstance(zone, Zone):
|
||||||
|
await player.send("You can't drop things here.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
thing = _find_thing_in_inventory(args.strip(), player)
|
||||||
|
if thing is None:
|
||||||
|
await player.send(f"You're not carrying '{args.strip()}'.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
thing.move_to(zone, x=player.x, y=player.y)
|
||||||
|
await player.send(f"You drop {thing.name}.\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_inventory(player: Player, args: str) -> None:
|
||||||
|
"""List items in the player's inventory."""
|
||||||
|
things = [obj for obj in player.contents if isinstance(obj, Thing)]
|
||||||
|
|
||||||
|
if not things:
|
||||||
|
await player.send("You aren't carrying anything.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = ["You are carrying:\r\n"]
|
||||||
|
for thing in things:
|
||||||
|
lines.append(f" {thing.name}\r\n")
|
||||||
|
|
||||||
|
await player.send("".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
register(CommandDefinition("get", cmd_get, aliases=["take"]))
|
||||||
|
register(CommandDefinition("drop", cmd_drop))
|
||||||
|
register(CommandDefinition("inventory", cmd_inventory, aliases=["i"]))
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""Base entity class for characters in the world."""
|
"""Base entity class for characters in the world."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from mudlib.object import Object
|
from mudlib.object import Object
|
||||||
|
|
@ -23,6 +25,12 @@ class Entity(Object):
|
||||||
defense_locked_until: float = 0.0 # monotonic time when defense recovery ends
|
defense_locked_until: float = 0.0 # monotonic time when defense recovery ends
|
||||||
resting: bool = False # whether this entity is currently resting
|
resting: bool = False # whether this entity is currently resting
|
||||||
|
|
||||||
|
def can_accept(self, obj: Object) -> bool:
|
||||||
|
"""Entities accept portable Things (inventory)."""
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
|
||||||
|
return isinstance(obj, Thing) and obj.portable
|
||||||
|
|
||||||
async def send(self, message: str) -> None:
|
async def send(self, message: str) -> None:
|
||||||
"""Send a message to this entity. Base implementation is a no-op."""
|
"""Send a message to this entity. Base implementation is a no-op."""
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,32 @@ class Object:
|
||||||
"""Everything whose location is this object."""
|
"""Everything whose location is this object."""
|
||||||
return list(self._contents)
|
return list(self._contents)
|
||||||
|
|
||||||
|
def move_to(
|
||||||
|
self,
|
||||||
|
destination: Object | None,
|
||||||
|
*,
|
||||||
|
x: int | None = None,
|
||||||
|
y: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Move this object to a new location.
|
||||||
|
|
||||||
|
Removes from old location's contents, updates the location pointer,
|
||||||
|
and adds to new location's contents. Coordinates are set from the
|
||||||
|
keyword arguments (cleared to None if not provided).
|
||||||
|
"""
|
||||||
|
# Remove from old location
|
||||||
|
if self.location is not None and self in self.location._contents:
|
||||||
|
self.location._contents.remove(self)
|
||||||
|
|
||||||
|
# Update location and coordinates
|
||||||
|
self.location = destination
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
|
||||||
|
# Add to new location
|
||||||
|
if destination is not None:
|
||||||
|
destination._contents.append(self)
|
||||||
|
|
||||||
def can_accept(self, obj: Object) -> bool:
|
def can_accept(self, obj: Object) -> bool:
|
||||||
"""Whether this object accepts obj as contents. Default: no."""
|
"""Whether this object accepts obj as contents. Default: no."""
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import mudlib.commands.play
|
||||||
import mudlib.commands.quit
|
import mudlib.commands.quit
|
||||||
import mudlib.commands.reload
|
import mudlib.commands.reload
|
||||||
import mudlib.commands.spawn
|
import mudlib.commands.spawn
|
||||||
|
import mudlib.commands.things
|
||||||
from mudlib.caps import parse_mtts
|
from mudlib.caps import parse_mtts
|
||||||
from mudlib.combat.commands import register_combat_commands
|
from mudlib.combat.commands import register_combat_commands
|
||||||
from mudlib.combat.engine import process_combat
|
from mudlib.combat.engine import process_combat
|
||||||
|
|
@ -30,6 +31,8 @@ from mudlib.effects import clear_expired
|
||||||
from mudlib.if_session import broadcast_to_spectators
|
from mudlib.if_session import broadcast_to_spectators
|
||||||
from mudlib.mob_ai import process_mobs
|
from mudlib.mob_ai import process_mobs
|
||||||
from mudlib.mobs import load_mob_templates, mob_templates
|
from mudlib.mobs import load_mob_templates, mob_templates
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.things import load_thing_templates, spawn_thing, thing_templates
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.resting import process_resting
|
from mudlib.resting import process_resting
|
||||||
from mudlib.store import (
|
from mudlib.store import (
|
||||||
|
|
@ -42,6 +45,8 @@ from mudlib.store import (
|
||||||
save_player,
|
save_player,
|
||||||
update_last_login,
|
update_last_login,
|
||||||
)
|
)
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.things import load_thing_templates, spawn_thing, thing_templates
|
||||||
from mudlib.world.terrain import World
|
from mudlib.world.terrain import World
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
@ -262,6 +267,7 @@ async def shell(
|
||||||
"max_stamina": 100.0,
|
"max_stamina": 100.0,
|
||||||
"flying": False,
|
"flying": False,
|
||||||
"zone_name": "overworld",
|
"zone_name": "overworld",
|
||||||
|
"inventory": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Resolve zone from zone_name (currently only overworld exists)
|
# Resolve zone from zone_name (currently only overworld exists)
|
||||||
|
|
@ -300,6 +306,20 @@ async def shell(
|
||||||
reader=_reader,
|
reader=_reader,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Reconstruct inventory from saved data
|
||||||
|
for item_name in player_data.get("inventory", []):
|
||||||
|
template = thing_templates.get(item_name)
|
||||||
|
if template:
|
||||||
|
spawn_thing(template, player)
|
||||||
|
else:
|
||||||
|
# Template not found — create a bare Thing so it's not lost
|
||||||
|
log.warning(
|
||||||
|
"unknown thing template '%s' for player '%s'",
|
||||||
|
item_name,
|
||||||
|
player_name,
|
||||||
|
)
|
||||||
|
Thing(name=item_name, location=player)
|
||||||
|
|
||||||
# Parse and store client capabilities from MTTS
|
# Parse and store client capabilities from MTTS
|
||||||
ttype3 = _writer.get_extra_info("ttype3")
|
ttype3 = _writer.get_extra_info("ttype3")
|
||||||
player.caps = parse_mtts(ttype3)
|
player.caps = parse_mtts(ttype3)
|
||||||
|
|
@ -463,6 +483,13 @@ async def run_server() -> None:
|
||||||
mob_templates.update(loaded)
|
mob_templates.update(loaded)
|
||||||
log.info("loaded %d mob templates from %s", len(loaded), mobs_dir)
|
log.info("loaded %d mob templates from %s", len(loaded), mobs_dir)
|
||||||
|
|
||||||
|
# Load thing templates
|
||||||
|
things_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "things"
|
||||||
|
if things_dir.exists():
|
||||||
|
loaded_things = load_thing_templates(things_dir)
|
||||||
|
thing_templates.update(loaded_things)
|
||||||
|
log.info("loaded %d thing templates from %s", len(loaded_things), things_dir)
|
||||||
|
|
||||||
# connect_maxwait: how long to wait for telnet option negotiation (CHARSET
|
# connect_maxwait: how long to wait for telnet option negotiation (CHARSET
|
||||||
# etc) before starting the shell. default is 4.0s which is painful.
|
# etc) before starting the shell. default is 4.0s which is painful.
|
||||||
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
|
||||||
|
|
||||||
class PlayerData(TypedDict):
|
class PlayerData(TypedDict):
|
||||||
|
|
@ -20,6 +22,7 @@ class PlayerData(TypedDict):
|
||||||
max_stamina: float
|
max_stamina: float
|
||||||
flying: bool
|
flying: bool
|
||||||
zone_name: str
|
zone_name: str
|
||||||
|
inventory: list[str]
|
||||||
|
|
||||||
|
|
||||||
# Module-level database path
|
# Module-level database path
|
||||||
|
|
@ -53,12 +56,13 @@ def init_db(db_path: str | Path) -> None:
|
||||||
max_stamina REAL NOT NULL DEFAULT 100.0,
|
max_stamina REAL NOT NULL DEFAULT 100.0,
|
||||||
flying INTEGER NOT NULL DEFAULT 0,
|
flying INTEGER NOT NULL DEFAULT 0,
|
||||||
zone_name TEXT NOT NULL DEFAULT 'overworld',
|
zone_name TEXT NOT NULL DEFAULT 'overworld',
|
||||||
|
inventory TEXT NOT NULL DEFAULT '[]',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
last_login TEXT
|
last_login TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Migration: add zone_name column if it doesn't exist
|
# Migrations: add columns if they don't exist (old schemas)
|
||||||
cursor.execute("PRAGMA table_info(accounts)")
|
cursor.execute("PRAGMA table_info(accounts)")
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
if "zone_name" not in columns:
|
if "zone_name" not in columns:
|
||||||
|
|
@ -66,6 +70,10 @@ def init_db(db_path: str | Path) -> None:
|
||||||
"ALTER TABLE accounts "
|
"ALTER TABLE accounts "
|
||||||
"ADD COLUMN zone_name TEXT NOT NULL DEFAULT 'overworld'"
|
"ADD COLUMN zone_name TEXT NOT NULL DEFAULT 'overworld'"
|
||||||
)
|
)
|
||||||
|
if "inventory" not in columns:
|
||||||
|
cursor.execute(
|
||||||
|
"ALTER TABLE accounts ADD COLUMN inventory TEXT NOT NULL DEFAULT '[]'"
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -188,6 +196,10 @@ def save_player(player: Player) -> None:
|
||||||
Args:
|
Args:
|
||||||
player: Player instance to save
|
player: Player instance to save
|
||||||
"""
|
"""
|
||||||
|
# Serialize inventory as JSON list of thing names
|
||||||
|
inventory_names = [obj.name for obj in player.contents if isinstance(obj, Thing)]
|
||||||
|
inventory_json = json.dumps(inventory_names)
|
||||||
|
|
||||||
conn = _get_connection()
|
conn = _get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
|
@ -195,7 +207,7 @@ def save_player(player: Player) -> None:
|
||||||
"""
|
"""
|
||||||
UPDATE accounts
|
UPDATE accounts
|
||||||
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?,
|
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?,
|
||||||
zone_name = ?
|
zone_name = ?, inventory = ?
|
||||||
WHERE name = ?
|
WHERE name = ?
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -206,6 +218,7 @@ def save_player(player: Player) -> None:
|
||||||
player.max_stamina,
|
player.max_stamina,
|
||||||
1 if player.flying else 0,
|
1 if player.flying else 0,
|
||||||
player.location.name if player.location else "overworld",
|
player.location.name if player.location else "overworld",
|
||||||
|
inventory_json,
|
||||||
player.name,
|
player.name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -226,29 +239,23 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
conn = _get_connection()
|
conn = _get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Check if zone_name column exists (for migration)
|
# Check which columns exist (for migration compatibility)
|
||||||
cursor.execute("PRAGMA table_info(accounts)")
|
cursor.execute("PRAGMA table_info(accounts)")
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
has_zone_name = "zone_name" in columns
|
has_zone_name = "zone_name" in columns
|
||||||
|
has_inventory = "inventory" in columns
|
||||||
|
|
||||||
|
# Build SELECT based on available columns
|
||||||
|
select_cols = "x, y, pl, stamina, max_stamina, flying"
|
||||||
if has_zone_name:
|
if has_zone_name:
|
||||||
cursor.execute(
|
select_cols += ", zone_name"
|
||||||
"""
|
if has_inventory:
|
||||||
SELECT x, y, pl, stamina, max_stamina, flying, zone_name
|
select_cols += ", inventory"
|
||||||
FROM accounts
|
|
||||||
WHERE name = ?
|
cursor.execute(
|
||||||
""",
|
f"SELECT {select_cols} FROM accounts WHERE name = ?",
|
||||||
(name,),
|
(name,),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
cursor.execute(
|
|
||||||
"""
|
|
||||||
SELECT x, y, pl, stamina, max_stamina, flying
|
|
||||||
FROM accounts
|
|
||||||
WHERE name = ?
|
|
||||||
""",
|
|
||||||
(name,),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = cursor.fetchone()
|
result = cursor.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -256,11 +263,18 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Unpack base fields
|
||||||
|
x, y, pl, stamina, max_stamina, flying_int = result[:6]
|
||||||
|
idx = 6
|
||||||
|
|
||||||
|
zone_name = "overworld"
|
||||||
if has_zone_name:
|
if has_zone_name:
|
||||||
x, y, pl, stamina, max_stamina, flying_int, zone_name = result
|
zone_name = result[idx]
|
||||||
else:
|
idx += 1
|
||||||
x, y, pl, stamina, max_stamina, flying_int = result
|
|
||||||
zone_name = "overworld" # Default for old schemas
|
inventory: list[str] = []
|
||||||
|
if has_inventory:
|
||||||
|
inventory = json.loads(result[idx])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"x": x,
|
"x": x,
|
||||||
|
|
@ -270,6 +284,7 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
"max_stamina": max_stamina,
|
"max_stamina": max_stamina,
|
||||||
"flying": bool(flying_int),
|
"flying": bool(flying_int),
|
||||||
"zone_name": zone_name,
|
"zone_name": zone_name,
|
||||||
|
"inventory": inventory,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
21
src/mudlib/thing.py
Normal file
21
src/mudlib/thing.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Thing — an item that can exist in zones or inventories."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from mudlib.object import Object
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Thing(Object):
|
||||||
|
"""An item in the world.
|
||||||
|
|
||||||
|
Things can be on the ground (location=zone, with x/y) or carried
|
||||||
|
by an entity (location=entity, no x/y). The portable flag controls
|
||||||
|
whether entities can pick them up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
description: str = ""
|
||||||
|
portable: bool = True
|
||||||
|
aliases: list[str] = field(default_factory=list)
|
||||||
62
src/mudlib/things.py
Normal file
62
src/mudlib/things.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Thing template loading, registry, and spawning."""
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mudlib.object import Object
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ThingTemplate:
|
||||||
|
"""Definition loaded from TOML — used to spawn Thing instances."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
portable: bool = True
|
||||||
|
aliases: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level registry
|
||||||
|
thing_templates: dict[str, ThingTemplate] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_thing_template(path: Path) -> ThingTemplate:
|
||||||
|
"""Parse a thing TOML file into a ThingTemplate."""
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
return ThingTemplate(
|
||||||
|
name=data["name"],
|
||||||
|
description=data["description"],
|
||||||
|
portable=data.get("portable", True),
|
||||||
|
aliases=data.get("aliases", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_thing_templates(directory: Path) -> dict[str, ThingTemplate]:
|
||||||
|
"""Load all .toml files in a directory into a dict keyed by name."""
|
||||||
|
templates: dict[str, ThingTemplate] = {}
|
||||||
|
for path in sorted(directory.glob("*.toml")):
|
||||||
|
template = load_thing_template(path)
|
||||||
|
templates[template.name] = template
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_thing(
|
||||||
|
template: ThingTemplate,
|
||||||
|
location: Object | None,
|
||||||
|
*,
|
||||||
|
x: int | None = None,
|
||||||
|
y: int | None = None,
|
||||||
|
) -> Thing:
|
||||||
|
"""Create a Thing instance from a template at the given location."""
|
||||||
|
return Thing(
|
||||||
|
name=template.name,
|
||||||
|
description=template.description,
|
||||||
|
portable=template.portable,
|
||||||
|
aliases=list(template.aliases),
|
||||||
|
location=location,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
)
|
||||||
|
|
@ -368,3 +368,67 @@ async def test_dispatch_allows_matching_mode(player):
|
||||||
await commands.dispatch(player, "strike")
|
await commands.dispatch(player, "strike")
|
||||||
|
|
||||||
assert called
|
assert called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_ground_items(player, test_zone):
|
||||||
|
"""look shows things on the ground at the player's position."""
|
||||||
|
from mudlib.player import players
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
|
||||||
|
players.clear()
|
||||||
|
players[player.name] = player
|
||||||
|
|
||||||
|
Thing(name="rock", location=test_zone, x=5, y=5)
|
||||||
|
await look.cmd_look(player, "")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "rock" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_multiple_ground_items(player, test_zone):
|
||||||
|
"""look shows all things at the player's position."""
|
||||||
|
from mudlib.player import players
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
|
||||||
|
players.clear()
|
||||||
|
players[player.name] = player
|
||||||
|
|
||||||
|
Thing(name="rock", location=test_zone, x=5, y=5)
|
||||||
|
Thing(name="sword", location=test_zone, x=5, y=5)
|
||||||
|
await look.cmd_look(player, "")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "rock" in output.lower()
|
||||||
|
assert "sword" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_no_ground_items_no_extra_output(player, test_zone):
|
||||||
|
"""look with no ground items doesn't mention items."""
|
||||||
|
from mudlib.player import players
|
||||||
|
|
||||||
|
players.clear()
|
||||||
|
players[player.name] = player
|
||||||
|
|
||||||
|
await look.cmd_look(player, "")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "on the ground" not in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_ignores_items_at_other_positions(player, test_zone):
|
||||||
|
"""look doesn't show items that are at different positions."""
|
||||||
|
from mudlib.player import players
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
|
||||||
|
players.clear()
|
||||||
|
players[player.name] = player
|
||||||
|
|
||||||
|
Thing(name="far_rock", location=test_zone, x=9, y=9)
|
||||||
|
await look.cmd_look(player, "")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "far_rock" not in output.lower()
|
||||||
|
|
|
||||||
296
tests/test_get_drop.py
Normal file
296
tests/test_get_drop.py
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
"""Tests for get and drop commands."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.commands import _registry
|
||||||
|
from mudlib.entity import Entity
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_zone():
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
return Zone(
|
||||||
|
name="testzone",
|
||||||
|
width=10,
|
||||||
|
height=10,
|
||||||
|
toroidal=True,
|
||||||
|
terrain=terrain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
|
p = Player(
|
||||||
|
name="TestPlayer",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
location=test_zone,
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# --- Object.move_to ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_to_updates_location():
|
||||||
|
"""move_to changes the object's location pointer."""
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
zone = Zone(name="test", width=10, height=10, terrain=terrain)
|
||||||
|
entity = Entity(name="player", location=zone, x=5, y=5)
|
||||||
|
rock = Thing(name="rock", location=zone, x=5, y=5)
|
||||||
|
|
||||||
|
rock.move_to(entity)
|
||||||
|
assert rock.location is entity
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_to_removes_from_old_contents():
|
||||||
|
"""move_to removes object from old location's contents."""
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
zone = Zone(name="test", width=10, height=10, terrain=terrain)
|
||||||
|
entity = Entity(name="player", location=zone, x=5, y=5)
|
||||||
|
rock = Thing(name="rock", location=zone, x=5, y=5)
|
||||||
|
|
||||||
|
rock.move_to(entity)
|
||||||
|
assert rock not in zone.contents
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_to_adds_to_new_contents():
|
||||||
|
"""move_to adds object to new location's contents."""
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
zone = Zone(name="test", width=10, height=10, terrain=terrain)
|
||||||
|
entity = Entity(name="player", location=zone, x=5, y=5)
|
||||||
|
rock = Thing(name="rock", location=zone, x=5, y=5)
|
||||||
|
|
||||||
|
rock.move_to(entity)
|
||||||
|
assert rock in entity.contents
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_to_from_none():
|
||||||
|
"""move_to works from location=None (template to world)."""
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
zone = Zone(name="test", width=10, height=10, terrain=terrain)
|
||||||
|
rock = Thing(name="rock")
|
||||||
|
|
||||||
|
rock.move_to(zone, x=3, y=7)
|
||||||
|
assert rock.location is zone
|
||||||
|
assert rock.x == 3
|
||||||
|
assert rock.y == 7
|
||||||
|
assert rock in zone.contents
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_to_sets_coordinates():
|
||||||
|
"""move_to can set x/y coordinates."""
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
zone = Zone(name="test", width=10, height=10, terrain=terrain)
|
||||||
|
rock = Thing(name="rock", location=zone, x=1, y=1)
|
||||||
|
|
||||||
|
entity = Entity(name="player", location=zone, x=5, y=5)
|
||||||
|
rock.move_to(entity)
|
||||||
|
# When moving to inventory, coordinates should be cleared
|
||||||
|
assert rock.x is None
|
||||||
|
assert rock.y is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_to_zone_sets_coordinates():
|
||||||
|
"""move_to a zone sets x/y from args."""
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
zone = Zone(name="test", width=10, height=10, terrain=terrain)
|
||||||
|
entity = Entity(name="player", location=zone, x=5, y=5)
|
||||||
|
rock = Thing(name="rock", location=entity)
|
||||||
|
|
||||||
|
rock.move_to(zone, x=3, y=7)
|
||||||
|
assert rock.x == 3
|
||||||
|
assert rock.y == 7
|
||||||
|
|
||||||
|
|
||||||
|
# --- cmd_get ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_picks_up_thing(player, test_zone):
|
||||||
|
"""get moves a thing from zone to player inventory."""
|
||||||
|
from mudlib.commands.things import cmd_get
|
||||||
|
|
||||||
|
rock = Thing(name="rock", location=test_zone, x=5, y=5)
|
||||||
|
await cmd_get(player, "rock")
|
||||||
|
assert rock.location is player
|
||||||
|
assert rock in player.contents
|
||||||
|
assert rock not in test_zone.contents
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_sends_confirmation(player, test_zone, mock_writer):
|
||||||
|
"""get sends feedback to the player."""
|
||||||
|
from mudlib.commands.things import cmd_get
|
||||||
|
|
||||||
|
Thing(name="rock", location=test_zone, x=5, y=5)
|
||||||
|
await cmd_get(player, "rock")
|
||||||
|
output = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "rock" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_nothing_there(player, test_zone, mock_writer):
|
||||||
|
"""get with no matching item gives feedback."""
|
||||||
|
from mudlib.commands.things import cmd_get
|
||||||
|
|
||||||
|
await cmd_get(player, "sword")
|
||||||
|
output = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "don't see" in output.lower() or "nothing" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_non_portable(player, test_zone, mock_writer):
|
||||||
|
"""get rejects non-portable things."""
|
||||||
|
from mudlib.commands.things import cmd_get
|
||||||
|
|
||||||
|
fountain = Thing(
|
||||||
|
name="fountain",
|
||||||
|
location=test_zone,
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
portable=False,
|
||||||
|
)
|
||||||
|
await cmd_get(player, "fountain")
|
||||||
|
# Fountain should still be in zone, not in player
|
||||||
|
assert fountain.location is test_zone
|
||||||
|
assert fountain not in player.contents
|
||||||
|
output = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "can't" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_no_args(player, mock_writer):
|
||||||
|
"""get with no arguments gives usage hint."""
|
||||||
|
from mudlib.commands.things import cmd_get
|
||||||
|
|
||||||
|
await cmd_get(player, "")
|
||||||
|
output = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "get what" in output.lower() or "what" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_matches_aliases(player, test_zone):
|
||||||
|
"""get matches thing aliases."""
|
||||||
|
from mudlib.commands.things import cmd_get
|
||||||
|
|
||||||
|
can = Thing(
|
||||||
|
name="pepsi can",
|
||||||
|
aliases=["can", "pepsi"],
|
||||||
|
location=test_zone,
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
)
|
||||||
|
await cmd_get(player, "pepsi")
|
||||||
|
assert can.location is player
|
||||||
|
|
||||||
|
|
||||||
|
# --- cmd_drop ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drop_puts_thing_on_ground(player, test_zone):
|
||||||
|
"""drop moves a thing from inventory to zone at player's position."""
|
||||||
|
from mudlib.commands.things import cmd_drop
|
||||||
|
|
||||||
|
rock = Thing(name="rock", location=player)
|
||||||
|
await cmd_drop(player, "rock")
|
||||||
|
assert rock.location is test_zone
|
||||||
|
assert rock.x == player.x
|
||||||
|
assert rock.y == player.y
|
||||||
|
assert rock in test_zone.contents
|
||||||
|
assert rock not in player.contents
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drop_sends_confirmation(player, test_zone, mock_writer):
|
||||||
|
"""drop sends feedback to the player."""
|
||||||
|
from mudlib.commands.things import cmd_drop
|
||||||
|
|
||||||
|
Thing(name="rock", location=player)
|
||||||
|
await cmd_drop(player, "rock")
|
||||||
|
output = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "rock" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drop_not_carrying(player, mock_writer):
|
||||||
|
"""drop with item not in inventory gives feedback."""
|
||||||
|
from mudlib.commands.things import cmd_drop
|
||||||
|
|
||||||
|
await cmd_drop(player, "sword")
|
||||||
|
output = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "not carrying" in output.lower() or "don't have" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drop_no_args(player, mock_writer):
|
||||||
|
"""drop with no arguments gives usage hint."""
|
||||||
|
from mudlib.commands.things import cmd_drop
|
||||||
|
|
||||||
|
await cmd_drop(player, "")
|
||||||
|
output = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "drop what" in output.lower() or "what" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drop_requires_zone(player, mock_writer):
|
||||||
|
"""drop without a zone location gives error."""
|
||||||
|
from mudlib.commands.things import cmd_drop
|
||||||
|
|
||||||
|
player.location = None
|
||||||
|
Thing(name="rock", location=player)
|
||||||
|
await cmd_drop(player, "rock")
|
||||||
|
output = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "nowhere" in output.lower() or "can't" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drop_matches_aliases(player, test_zone):
|
||||||
|
"""drop matches thing aliases."""
|
||||||
|
from mudlib.commands.things import cmd_drop
|
||||||
|
|
||||||
|
can = Thing(
|
||||||
|
name="pepsi can",
|
||||||
|
aliases=["can", "pepsi"],
|
||||||
|
location=player,
|
||||||
|
)
|
||||||
|
await cmd_drop(player, "can")
|
||||||
|
assert can.location is test_zone
|
||||||
|
|
||||||
|
|
||||||
|
# --- command registration ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_command_registered():
|
||||||
|
"""get command is registered."""
|
||||||
|
import mudlib.commands.things # noqa: F401
|
||||||
|
|
||||||
|
assert "get" in _registry
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_command_registered():
|
||||||
|
"""drop command is registered."""
|
||||||
|
import mudlib.commands.things # noqa: F401
|
||||||
|
|
||||||
|
assert "drop" in _registry
|
||||||
105
tests/test_inventory.py
Normal file
105
tests/test_inventory.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""Tests for inventory command."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib import commands
|
||||||
|
from mudlib.commands import things # noqa: F401
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_zone():
|
||||||
|
"""Create a test zone."""
|
||||||
|
return Zone(
|
||||||
|
name="testzone",
|
||||||
|
width=10,
|
||||||
|
height=10,
|
||||||
|
toroidal=True,
|
||||||
|
terrain=[["." for _ in range(10)] for _ in range(10)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
"""Create a mock writer."""
|
||||||
|
return MagicMock(write=MagicMock(), drain=AsyncMock())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
"""Create a mock reader."""
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
|
"""Create a test player."""
|
||||||
|
return Player(
|
||||||
|
name="TestPlayer",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
location=test_zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inventory_empty(player, mock_writer):
|
||||||
|
"""Test inventory with no items."""
|
||||||
|
from mudlib.commands.things import cmd_inventory
|
||||||
|
|
||||||
|
await cmd_inventory(player, "")
|
||||||
|
|
||||||
|
output = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "You aren't carrying anything." in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inventory_one_item(player, mock_writer):
|
||||||
|
"""Test inventory with one item."""
|
||||||
|
from mudlib.commands.things import cmd_inventory
|
||||||
|
|
||||||
|
Thing(name="rock", location=player)
|
||||||
|
|
||||||
|
await cmd_inventory(player, "")
|
||||||
|
|
||||||
|
output = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "You are carrying:" in output
|
||||||
|
assert "rock" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inventory_multiple_items(player, mock_writer):
|
||||||
|
"""Test inventory with multiple items."""
|
||||||
|
from mudlib.commands.things import cmd_inventory
|
||||||
|
|
||||||
|
Thing(name="rock", location=player)
|
||||||
|
Thing(name="sword", location=player)
|
||||||
|
Thing(name="shield", location=player)
|
||||||
|
|
||||||
|
await cmd_inventory(player, "")
|
||||||
|
|
||||||
|
output = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "You are carrying:" in output
|
||||||
|
assert "rock" in output
|
||||||
|
assert "sword" in output
|
||||||
|
assert "shield" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_inventory_command_registered():
|
||||||
|
"""Test that inventory command is registered."""
|
||||||
|
assert "inventory" in commands._registry
|
||||||
|
cmd = commands._registry["inventory"]
|
||||||
|
assert cmd.name == "inventory"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inventory_alias_registered():
|
||||||
|
"""Test that 'i' alias is registered."""
|
||||||
|
assert "i" in commands._registry
|
||||||
|
cmd = commands._registry["i"]
|
||||||
|
assert cmd.name == "inventory"
|
||||||
170
tests/test_inventory_persistence.py
Normal file
170
tests/test_inventory_persistence.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
"""Tests for inventory persistence."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.store import (
|
||||||
|
create_account,
|
||||||
|
init_db,
|
||||||
|
load_player_data,
|
||||||
|
save_player,
|
||||||
|
)
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.things import ThingTemplate, thing_templates
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(tmp_path):
|
||||||
|
"""Initialize a temporary database."""
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
init_db(db_path)
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_zone():
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
return Zone(
|
||||||
|
name="testzone",
|
||||||
|
width=10,
|
||||||
|
height=10,
|
||||||
|
terrain=terrain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def register_templates():
|
||||||
|
"""Register test templates."""
|
||||||
|
thing_templates.clear()
|
||||||
|
thing_templates["rock"] = ThingTemplate(
|
||||||
|
name="rock",
|
||||||
|
description="a rock",
|
||||||
|
aliases=["stone"],
|
||||||
|
)
|
||||||
|
thing_templates["sword"] = ThingTemplate(
|
||||||
|
name="sword",
|
||||||
|
description="a sword",
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
thing_templates.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# --- save ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_player_with_inventory(db, test_zone):
|
||||||
|
"""save_player persists inventory as JSON list of template names."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
create_account("Alice", "pass123")
|
||||||
|
player = Player(
|
||||||
|
name="Alice",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
location=test_zone,
|
||||||
|
)
|
||||||
|
Thing(name="rock", location=player)
|
||||||
|
Thing(name="sword", location=player)
|
||||||
|
|
||||||
|
save_player(player)
|
||||||
|
|
||||||
|
# Check raw DB value
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT inventory FROM accounts WHERE name = ?", ("Alice",))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert row is not None
|
||||||
|
inventory = json.loads(row[0])
|
||||||
|
assert sorted(inventory) == ["rock", "sword"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_player_empty_inventory(db, test_zone):
|
||||||
|
"""save_player stores empty list when no inventory."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
create_account("Bob", "pass123")
|
||||||
|
player = Player(
|
||||||
|
name="Bob",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
location=test_zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
save_player(player)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT inventory FROM accounts WHERE name = ?", ("Bob",))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert row is not None
|
||||||
|
assert json.loads(row[0]) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- load ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_player_data_includes_inventory(db):
|
||||||
|
"""load_player_data returns inventory list."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
create_account("Alice", "pass123")
|
||||||
|
|
||||||
|
# Manually set inventory in DB
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE accounts SET inventory = ? WHERE name = ?",
|
||||||
|
(json.dumps(["rock", "sword"]), "Alice"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
data = load_player_data("Alice")
|
||||||
|
assert data is not None
|
||||||
|
assert sorted(data["inventory"]) == ["rock", "sword"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_player_data_empty_inventory(db):
|
||||||
|
"""load_player_data returns empty list for no inventory."""
|
||||||
|
create_account("Bob", "pass123")
|
||||||
|
data = load_player_data("Bob")
|
||||||
|
assert data is not None
|
||||||
|
assert data["inventory"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_load_duplicate_items(db, test_zone):
|
||||||
|
"""Duplicate items (two rocks) round-trip correctly."""
|
||||||
|
create_account("Dupes", "pass123")
|
||||||
|
player = Player(
|
||||||
|
name="Dupes",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
location=test_zone,
|
||||||
|
)
|
||||||
|
Thing(name="rock", location=player)
|
||||||
|
Thing(name="rock", location=player)
|
||||||
|
|
||||||
|
save_player(player)
|
||||||
|
data = load_player_data("Dupes")
|
||||||
|
assert data is not None
|
||||||
|
assert data["inventory"] == ["rock", "rock"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_player_data_migration_no_column(db):
|
||||||
|
"""Old DB without inventory column returns empty list."""
|
||||||
|
|
||||||
|
create_account("Charlie", "pass123")
|
||||||
|
|
||||||
|
# init_db already ran the migration adding the inventory column,
|
||||||
|
# so this just verifies a fresh account gets empty inventory
|
||||||
|
|
||||||
|
data = load_player_data("Charlie")
|
||||||
|
assert data is not None
|
||||||
|
assert data["inventory"] == []
|
||||||
116
tests/test_thing.py
Normal file
116
tests/test_thing.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Tests for the Thing class."""
|
||||||
|
|
||||||
|
from mudlib.entity import Entity
|
||||||
|
from mudlib.object import Object
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
# --- construction ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_creation_minimal():
|
||||||
|
"""Thing can be created with just a name."""
|
||||||
|
t = Thing(name="rock")
|
||||||
|
assert t.name == "rock"
|
||||||
|
assert t.location is None
|
||||||
|
assert t.description == ""
|
||||||
|
assert t.portable is True # default: things are portable
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_creation_with_description():
|
||||||
|
"""Thing can have a description."""
|
||||||
|
t = Thing(name="sword", description="a rusty iron sword")
|
||||||
|
assert t.description == "a rusty iron sword"
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_creation_non_portable():
|
||||||
|
"""Thing can be marked as non-portable (fixture, scenery)."""
|
||||||
|
t = Thing(name="fountain", portable=False)
|
||||||
|
assert t.portable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_in_zone():
|
||||||
|
"""Thing can be placed in a zone with coordinates."""
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
zone = Zone(name="test", width=10, height=10, terrain=terrain)
|
||||||
|
rock = Thing(name="rock", location=zone, x=3, y=7)
|
||||||
|
assert rock.location is zone
|
||||||
|
assert rock.x == 3
|
||||||
|
assert rock.y == 7
|
||||||
|
assert rock in zone.contents
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_in_entity_inventory():
|
||||||
|
"""Thing can be placed in an entity (inventory)."""
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
zone = Zone(name="test", width=10, height=10, terrain=terrain)
|
||||||
|
entity = Entity(name="player", location=zone, x=5, y=5)
|
||||||
|
sword = Thing(name="sword", location=entity)
|
||||||
|
assert sword.location is entity
|
||||||
|
assert sword in entity.contents
|
||||||
|
# Inventory items don't need coordinates
|
||||||
|
assert sword.x is None
|
||||||
|
assert sword.y is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_is_subclass_of_object():
|
||||||
|
"""Thing inherits from Object."""
|
||||||
|
t = Thing(name="gem")
|
||||||
|
assert isinstance(t, Object)
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_aliases_default_empty():
|
||||||
|
"""Thing aliases default to empty list."""
|
||||||
|
t = Thing(name="rock")
|
||||||
|
assert t.aliases == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_aliases():
|
||||||
|
"""Thing can have aliases for matching."""
|
||||||
|
t = Thing(name="pepsi can", aliases=["can", "pepsi"])
|
||||||
|
assert t.aliases == ["can", "pepsi"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- entity.can_accept ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_can_accept_portable_thing():
|
||||||
|
"""Entity accepts portable things (inventory)."""
|
||||||
|
entity = Entity(name="player")
|
||||||
|
sword = Thing(name="sword", portable=True)
|
||||||
|
assert entity.can_accept(sword) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_rejects_non_portable_thing():
|
||||||
|
"""Entity rejects non-portable things."""
|
||||||
|
entity = Entity(name="player")
|
||||||
|
fountain = Thing(name="fountain", portable=False)
|
||||||
|
assert entity.can_accept(fountain) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_rejects_non_thing():
|
||||||
|
"""Entity rejects objects that aren't Things."""
|
||||||
|
entity = Entity(name="player")
|
||||||
|
other = Object(name="abstract")
|
||||||
|
assert entity.can_accept(other) is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- zone interaction ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_contents_at_finds_things():
|
||||||
|
"""Zone.contents_at finds things at a position."""
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
zone = Zone(name="test", width=10, height=10, terrain=terrain)
|
||||||
|
rock = Thing(name="rock", location=zone, x=5, y=5)
|
||||||
|
result = zone.contents_at(5, 5)
|
||||||
|
assert rock in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_contents_near_finds_things():
|
||||||
|
"""Zone.contents_near finds things within range."""
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
zone = Zone(name="test", width=10, height=10, terrain=terrain)
|
||||||
|
rock = Thing(name="rock", location=zone, x=5, y=5)
|
||||||
|
result = zone.contents_near(5, 5, 3)
|
||||||
|
assert rock in result
|
||||||
152
tests/test_thing_templates.py
Normal file
152
tests/test_thing_templates.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
"""Tests for thing template loading and spawning."""
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.things import (
|
||||||
|
ThingTemplate,
|
||||||
|
load_thing_template,
|
||||||
|
load_thing_templates,
|
||||||
|
spawn_thing,
|
||||||
|
)
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_zone():
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
return Zone(
|
||||||
|
name="testzone",
|
||||||
|
width=10,
|
||||||
|
height=10,
|
||||||
|
terrain=terrain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- ThingTemplate ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_template_creation():
|
||||||
|
"""ThingTemplate holds definition data."""
|
||||||
|
t = ThingTemplate(
|
||||||
|
name="rock",
|
||||||
|
description="a smooth grey rock",
|
||||||
|
portable=True,
|
||||||
|
aliases=["stone"],
|
||||||
|
)
|
||||||
|
assert t.name == "rock"
|
||||||
|
assert t.description == "a smooth grey rock"
|
||||||
|
assert t.portable is True
|
||||||
|
assert t.aliases == ["stone"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_template_defaults():
|
||||||
|
"""ThingTemplate has sensible defaults."""
|
||||||
|
t = ThingTemplate(name="rock", description="a rock")
|
||||||
|
assert t.portable is True
|
||||||
|
assert t.aliases == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- load_thing_template ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_thing_template(tmp_path):
|
||||||
|
"""Load a thing template from a TOML file."""
|
||||||
|
toml_content = textwrap.dedent("""\
|
||||||
|
name = "rusty sword"
|
||||||
|
description = "a sword covered in rust"
|
||||||
|
portable = true
|
||||||
|
aliases = ["sword", "rusty"]
|
||||||
|
""")
|
||||||
|
p = tmp_path / "sword.toml"
|
||||||
|
p.write_text(toml_content)
|
||||||
|
|
||||||
|
template = load_thing_template(p)
|
||||||
|
assert template.name == "rusty sword"
|
||||||
|
assert template.description == "a sword covered in rust"
|
||||||
|
assert template.portable is True
|
||||||
|
assert template.aliases == ["sword", "rusty"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_thing_template_minimal(tmp_path):
|
||||||
|
"""Load thing template with only required fields."""
|
||||||
|
toml_content = textwrap.dedent("""\
|
||||||
|
name = "rock"
|
||||||
|
description = "a rock"
|
||||||
|
""")
|
||||||
|
p = tmp_path / "rock.toml"
|
||||||
|
p.write_text(toml_content)
|
||||||
|
|
||||||
|
template = load_thing_template(p)
|
||||||
|
assert template.name == "rock"
|
||||||
|
assert template.portable is True # default
|
||||||
|
assert template.aliases == [] # default
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_thing_template_non_portable(tmp_path):
|
||||||
|
"""Load a non-portable thing template."""
|
||||||
|
toml_content = textwrap.dedent("""\
|
||||||
|
name = "fountain"
|
||||||
|
description = "a stone fountain"
|
||||||
|
portable = false
|
||||||
|
""")
|
||||||
|
p = tmp_path / "fountain.toml"
|
||||||
|
p.write_text(toml_content)
|
||||||
|
|
||||||
|
template = load_thing_template(p)
|
||||||
|
assert template.portable is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- load_thing_templates ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_thing_templates(tmp_path):
|
||||||
|
"""Load all thing templates from a directory."""
|
||||||
|
for name in ["rock", "sword"]:
|
||||||
|
p = tmp_path / f"{name}.toml"
|
||||||
|
p.write_text(f'name = "{name}"\ndescription = "a {name}"\n')
|
||||||
|
|
||||||
|
templates = load_thing_templates(tmp_path)
|
||||||
|
assert "rock" in templates
|
||||||
|
assert "sword" in templates
|
||||||
|
assert len(templates) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_thing_templates_empty_dir(tmp_path):
|
||||||
|
"""Empty directory returns empty dict."""
|
||||||
|
templates = load_thing_templates(tmp_path)
|
||||||
|
assert templates == {}
|
||||||
|
|
||||||
|
|
||||||
|
# --- spawn_thing ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_thing_in_zone(test_zone):
|
||||||
|
"""spawn_thing creates a Thing instance in a zone."""
|
||||||
|
template = ThingTemplate(
|
||||||
|
name="rock",
|
||||||
|
description="a smooth grey rock",
|
||||||
|
portable=True,
|
||||||
|
aliases=["stone"],
|
||||||
|
)
|
||||||
|
thing = spawn_thing(template, test_zone, x=3, y=7)
|
||||||
|
assert isinstance(thing, Thing)
|
||||||
|
assert thing.name == "rock"
|
||||||
|
assert thing.description == "a smooth grey rock"
|
||||||
|
assert thing.portable is True
|
||||||
|
assert thing.aliases == ["stone"]
|
||||||
|
assert thing.location is test_zone
|
||||||
|
assert thing.x == 3
|
||||||
|
assert thing.y == 7
|
||||||
|
assert thing in test_zone.contents
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_thing_without_coordinates(test_zone):
|
||||||
|
"""spawn_thing can create a thing without position (template/inventory)."""
|
||||||
|
template = ThingTemplate(name="gem", description="a sparkling gem")
|
||||||
|
thing = spawn_thing(template, None)
|
||||||
|
assert thing.location is None
|
||||||
|
assert thing.x is None
|
||||||
|
assert thing.y is None
|
||||||
Loading…
Reference in a new issue