Compare commits
No commits in common. "05a739da74c31a66da8cb2ddbb2abfcd9906a1d6" and "957a411601a11aefab21b59716fe876d0fed6fe2" have entirely different histories.
05a739da74
...
957a411601
16 changed files with 24 additions and 1199 deletions
|
|
@ -1,3 +0,0 @@
|
||||||
name = "fountain"
|
|
||||||
description = "a weathered stone fountain, water trickling into a mossy basin"
|
|
||||||
portable = false
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
name = "rock"
|
|
||||||
description = "a smooth grey rock, worn by wind and rain"
|
|
||||||
portable = true
|
|
||||||
aliases = ["stone"]
|
|
||||||
|
|
@ -5,7 +5,6 @@ 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
|
||||||
|
|
@ -100,15 +99,6 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
"""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,7 +1,5 @@
|
||||||
"""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
|
||||||
|
|
@ -25,12 +23,6 @@ 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,32 +32,6 @@ 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,7 +22,6 @@ 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
|
||||||
|
|
@ -31,8 +30,6 @@ 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 (
|
||||||
|
|
@ -45,8 +42,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -267,7 +262,6 @@ 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)
|
||||||
|
|
@ -306,20 +300,6 @@ 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)
|
||||||
|
|
@ -483,13 +463,6 @@ 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,14 +2,12 @@
|
||||||
|
|
||||||
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):
|
||||||
|
|
@ -22,7 +20,6 @@ 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
|
||||||
|
|
@ -56,13 +53,12 @@ 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
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Migrations: add columns if they don't exist (old schemas)
|
# Migration: add zone_name column if it doesn't exist
|
||||||
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:
|
||||||
|
|
@ -70,10 +66,6 @@ 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()
|
||||||
|
|
@ -196,10 +188,6 @@ 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()
|
||||||
|
|
||||||
|
|
@ -207,7 +195,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 = ?, inventory = ?
|
zone_name = ?
|
||||||
WHERE name = ?
|
WHERE name = ?
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -218,7 +206,6 @@ 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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -239,23 +226,29 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
conn = _get_connection()
|
conn = _get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Check which columns exist (for migration compatibility)
|
# Check if zone_name column exists (for migration)
|
||||||
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:
|
||||||
select_cols += ", zone_name"
|
cursor.execute(
|
||||||
if has_inventory:
|
"""
|
||||||
select_cols += ", inventory"
|
SELECT x, y, pl, stamina, max_stamina, flying, zone_name
|
||||||
|
FROM accounts
|
||||||
cursor.execute(
|
WHERE name = ?
|
||||||
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()
|
||||||
|
|
@ -263,18 +256,11 @@ 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:
|
||||||
zone_name = result[idx]
|
x, y, pl, stamina, max_stamina, flying_int, zone_name = result
|
||||||
idx += 1
|
else:
|
||||||
|
x, y, pl, stamina, max_stamina, flying_int = result
|
||||||
inventory: list[str] = []
|
zone_name = "overworld" # Default for old schemas
|
||||||
if has_inventory:
|
|
||||||
inventory = json.loads(result[idx])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"x": x,
|
"x": x,
|
||||||
|
|
@ -284,7 +270,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
"""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,67 +368,3 @@ 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()
|
|
||||||
|
|
|
||||||
|
|
@ -1,296 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
"""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"
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
"""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"] == []
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
"""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