Player Housing¶
Problem¶
You want players to claim a room as their personal house, lock the door so only they can enter, and place furniture inside.
Solution¶
The Ownership Component¶
from uuid import UUID
from pydantic import Field
from maid_engine.core.ecs import Component
class HouseOwnershipComponent(Component):
"""Marks a room as player-owned housing."""
owner_id: UUID
owner_name: str = ""
allowed_visitors: list[UUID] = Field(default_factory=list) # Players who can enter
max_furniture: int = 10
furniture_count: int = 0
Claiming a Room¶
from uuid import UUID
from maid_engine.commands.decorators import command
from maid_engine.commands import CommandContext
from maid_stdlib.components import (
DescriptionComponent,
MetadataComponent,
)
@command(
name="claim",
category="housing",
help_text="Claim an unclaimed room as your house",
)
async def cmd_claim(ctx: CommandContext) -> bool:
"""Claim the current room as a player house."""
room_id = ctx.world.get_entity_room(ctx.player_id)
if not room_id:
return False
room = ctx.world.get_entity(room_id)
if not room:
return False
# Check if already claimed
existing = room.try_get(HouseOwnershipComponent)
if existing:
await ctx.session.send(
f"This room is already owned by {existing.owner_name}.\n"
)
return False
# Must be tagged as claimable
if not room.has_tag("claimable"):
await ctx.session.send("This room cannot be claimed.\n")
return False
# Claim it
player = ctx.world.get_entity(ctx.player_id)
if not player:
return False
player_desc = player.try_get(DescriptionComponent)
owner_name = player_desc.name if player_desc else "Unknown"
room.add(HouseOwnershipComponent(
owner_id=ctx.player_id,
owner_name=owner_name,
))
room.remove_tag("claimable")
room.add_tag("player_house")
# Access is enforced by HouseAccessSystem (below), which bounces
# non-owner/non-visitor players back out. We deliberately do NOT set
# ExitInfo.locked here: a locked door blocks *everyone* (including the
# owner) at the movement door-check, and ExitInfo.key_id is not consulted
# during movement — so a per-player "key" string cannot express an ACL.
# Track ownership metadata
meta = room.try_get(MetadataComponent)
if meta:
meta.record_modification(
"owner", modified_by=owner_name, new_value=str(ctx.player_id),
)
await ctx.session.send(
"You claim this room as your house! "
"Only you and invited visitors may enter.\n"
)
return True
Placing Furniture¶
from maid_engine.commands.decorators import command, arguments
from maid_engine.commands.arguments import ArgumentSpec, ArgumentType, ParsedArguments
from maid_engine.commands import CommandContext
from maid_stdlib.components import (
DescriptionComponent,
InventoryComponent,
ItemComponent,
PositionComponent,
)
FURNITURE_TEMPLATES: dict[str, dict[str, str]] = {
"chair": {
"name": "Wooden Chair",
"short_desc": "a sturdy wooden chair",
"long_desc": "A simple but well-crafted wooden chair.",
},
"table": {
"name": "Oak Table",
"short_desc": "a solid oak table",
"long_desc": "A heavy oak table with carved legs.",
},
"bed": {
"name": "Comfortable Bed",
"short_desc": "a comfortable-looking bed",
"long_desc": "A bed with a thick mattress and warm blankets.",
},
}
@command(
name="furnish",
category="housing",
help_text="Place a piece of furniture in your house",
)
@arguments(
ArgumentSpec("furniture", ArgumentType.STRING, description="Furniture type to place"),
)
async def cmd_furnish(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Place furniture in the player's house."""
room_id = ctx.world.get_entity_room(ctx.player_id)
if not room_id:
return False
room = ctx.world.get_entity(room_id)
if not room:
return False
ownership = room.try_get(HouseOwnershipComponent)
if not ownership or ownership.owner_id != ctx.player_id:
await ctx.session.send("You can only furnish your own house.\n")
return False
if ownership.furniture_count >= ownership.max_furniture:
await ctx.session.send("Your house is full! Remove something first.\n")
return False
furniture_type: str = args["furniture"].lower()
template = FURNITURE_TEMPLATES.get(furniture_type)
if not template:
types = ", ".join(FURNITURE_TEMPLATES.keys())
await ctx.session.send(
f"Unknown furniture type. Available: {types}\n"
)
return False
# Create the furniture entity in the room
furniture = ctx.world.create_entity()
furniture.add(DescriptionComponent(
name=template["name"],
short_desc=template["short_desc"],
long_desc=template["long_desc"],
keywords=[furniture_type, "furniture"],
))
furniture.add(ItemComponent(item_type="furniture", weight=50.0))
# Durable location: place_entity_in_room() only updates a PositionComponent
# if one exists, and the persisted room is derived from it. Without this the
# furniture would vanish from the house on reload.
furniture.add(PositionComponent(room_id=room_id))
furniture.add_tag("item")
furniture.add_tag("furniture")
ctx.world.place_entity_in_room(furniture.id, room_id)
ownership.furniture_count += 1
ownership.notify_mutation()
await ctx.session.send(
f"You place a {template['name']} in your house.\n"
)
return True
@command(
name="unfurnish",
aliases=["remove_furniture"],
category="housing",
help_text="Remove a piece of furniture from your house",
)
@arguments(
ArgumentSpec("target", ArgumentType.STRING, description="Furniture to remove"),
)
async def cmd_unfurnish(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Remove furniture from the player's house."""
room_id = ctx.world.get_entity_room(ctx.player_id)
if not room_id:
return False
room = ctx.world.get_entity(room_id)
if not room:
return False
ownership = room.try_get(HouseOwnershipComponent)
if not ownership or ownership.owner_id != ctx.player_id:
await ctx.session.send("You can only modify your own house.\n")
return False
keyword: str = args["target"]
for entity in ctx.world.entities_in_room(room_id):
if not entity.has_tag("furniture"):
continue
desc = entity.try_get(DescriptionComponent)
if desc and desc.matches_keyword(keyword):
name = desc.name
ctx.world.destroy_entity(entity.id)
ownership.furniture_count -= 1
ownership.notify_mutation()
await ctx.session.send(f"You remove the {name}.\n")
return True
await ctx.session.send(f"No furniture matching '{keyword}' here.\n")
return False
Allowing Visitors¶
from maid_engine.commands.decorators import command, arguments
from maid_engine.commands.arguments import ArgumentSpec, ArgumentType, ParsedArguments
from maid_engine.commands import CommandContext
@command(
name="allow",
category="housing",
help_text="Allow a player to enter your house",
)
@arguments(
ArgumentSpec("player", ArgumentType.STRING, description="Player name to allow"),
)
async def cmd_allow_visitor(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Add a player to the allowed visitors list."""
room_id = ctx.world.get_entity_room(ctx.player_id)
if not room_id:
return False
room = ctx.world.get_entity(room_id)
if not room:
return False
ownership = room.try_get(HouseOwnershipComponent)
if not ownership or ownership.owner_id != ctx.player_id:
await ctx.session.send("You can only manage your own house.\n")
return False
player_name: str = args["player"]
# Find the player entity by name
for entity in ctx.world.get_all_entities():
if not entity.has_tag("player"):
continue
from maid_stdlib.components import DescriptionComponent
desc = entity.try_get(DescriptionComponent)
if desc and desc.name.lower() == player_name.lower():
if entity.id not in ownership.allowed_visitors:
ownership.allowed_visitors.append(entity.id)
ownership.notify_mutation()
await ctx.session.send(
f"{desc.name} can now enter your house.\n"
)
return True
await ctx.session.send(f"Player '{player_name}' not found.\n")
return False
Enforcing Access¶
The allowed_visitors list is only an ACL — something has to enforce it. Register
a system that watches room entries and bounces unauthorized players back out:
from maid_engine.core.ecs import System
from maid_engine.core.events import RoomEnterEvent
class HouseAccessSystem(System):
"""Bounces non-owner/non-visitor players out of claimed houses."""
priority = 5 # Run early
async def startup(self) -> None:
# Store the handler id so shutdown() can unsubscribe; a leaked handler
# would keep firing against a torn-down system after a hot-reload.
self._enter_sub = self.events.subscribe(RoomEnterEvent, self._enforce_access)
async def shutdown(self) -> None:
self.events.unsubscribe(self._enter_sub)
async def update(self, delta: float) -> None:
return None # Event-driven; no per-tick work
async def _enforce_access(self, event: RoomEnterEvent) -> None:
room = self.world.get_entity(event.room_id)
if not room:
return
ownership = room.try_get(HouseOwnershipComponent)
if not ownership:
return # Not a house — no restriction
entity = self.world.get_entity(event.entity_id)
if not entity or not entity.has_tag("player"):
return # Only gate players
if (
event.entity_id == ownership.owner_id
or event.entity_id in ownership.allowed_visitors
):
return # Authorized
# Unauthorized: bounce back where they came from.
if event.from_room_id:
self.world.move_entity(event.entity_id, event.from_room_id)
RoomEnterEventfires after the move completes. As with the Level Gating recipe,World.move_entity()performs the move before emittingRoomEnterEvent, so this system is a reactive bounce-back: the intruder briefly enters, then is moved back.event.cancel()would not undo the move. Do not setExitInfo.locked = Trueon the house's entrance to "help" — that blocks the owner too (the door-check runs before movement) and the enter event never fires. For a hard pre-move gate, gate a dedicatedenter <house>command with a customis_owner_or_visitor()lock function instead (see Variations).
Return HouseAccessSystem from your content pack's get_systems() so it runs
each tick's event processing. Also register the custom HouseOwnershipComponent
for persistence in register_component_types, or claimed houses lose their owner
and visitor list on restart:
from maid_engine.persistence.registry import ComponentRegistry
def register_component_types(self, registry: ComponentRegistry) -> None:
registry.register(HouseOwnershipComponent, pack_name="my-pack")
Setting Up Claimable Rooms¶
During your content pack's on_load, prepare rooms for housing:
from uuid import UUID
from maid_engine.core.world import World
from maid_stdlib.components import (
DescriptionComponent,
ExitMetadataComponent,
ExitInfo,
)
async def create_claimable_house(world: World, district_room_id: UUID) -> UUID:
"""Create a single claimable house reachable from the district.
Returns the new house's room id.
Two hard constraints this illustrates:
* **Exits must use real compass directions.** The movement command only
accepts directions in ``ALL_DIRECTIONS`` (``n/s/e/w/up/down`` and the
diagonals); ``go out`` / ``go in`` are rejected as "not a valid
direction" and there is no standalone ``out``/``in`` command. So the
house is entered via ``north`` and left via ``south`` — never ``in``/
``out``, which would build an exit no player could ever traverse.
* **This topology is volatile.** Rooms registered with
``world.register_room`` and their exit dicts live only in memory. Entity
persistence saves entity *components and tags*, not the World's room
registry or exit graph (see the Locked Door recipe), so this district and
its links are lost on restart. For a durable, multi-house neighborhood,
author the district and house shells in YAML with stable ids and explicit
bidirectional exits, and do only claiming / ownership / furnishing at
runtime. (A single district room can hold only a handful of compass exits
anyway, so YAML is the right tool for a real neighborhood.)
"""
house = world.create_entity()
house.add(DescriptionComponent(
name="Empty House",
short_desc="an empty house waiting for an owner",
long_desc="Bare walls and dusty floors. This house needs a tenant.",
keywords=["house", "empty"],
))
house.add(ExitMetadataComponent(
exits={
"south": ExitInfo(door=True, door_name="front door"),
},
))
house.add_tag("room")
house.add_tag("claimable")
world.register_room(house.id, {"name": "Empty House", "exits": {}})
world.place_entity_in_room(house.id, district_room_id)
# Wire a traversable, bidirectional link using real compass directions:
# district --north--> house house --south--> district
# Both sides must be dict-backed rooms for the exit keys to be added.
house_data = world.get_room(house.id)
if isinstance(house_data, dict):
house_data.setdefault("exits", {})["south"] = district_room_id
district_data = world.get_room(district_room_id)
if isinstance(district_data, dict):
district_data.setdefault("exits", {})["north"] = house.id
return house.id
Builder Commands¶
You can also set up claimable rooms in-game:
@create room Empty Cottage
@describe here A cozy cottage with a stone fireplace. Unclaimed.
@attribute #<room-uuid> add claimable
@dig south = Housing District --door
Use a compass direction, not
in/out.@dig south = Housing Districtdigs a room to the south and (because@digmakes a two-way exit) auto-creates the reversenorthexit, so players enter the cottage from the district by typingnorth.@dig out = ...would create an exit keyedoutthat the movement command rejects (go out→ "not a valid direction"), so no one could walk through it.Why
#<room-uuid>and nothere?cmd_claimabove reads the "claimable" tag from the room's room-ref entity (ctx.world.get_entity(room_id)), which shares the room's UUID.@attributeis entity-only and rejects a room target, so@attribute here add claimablefails. Instead, tag the room-ref entity by its UUID —@create roomprints it ("Created room '…' with ID:<uuid>"), and@dig-created rooms also register a room-ref entity with the room's ID. Note that@create roomreuses your current room's ID (it renames the room you are standing in) and does not move you; run it from a throwaway room.
@dig ... --door records the door on the room's ExitMetadataComponent; there
is no supported @set here/exits/south/... path (a room entity has no exits
attribute). See the Locked Door recipe for the exit-metadata
details.
How It Works¶
- HouseOwnershipComponent tracks the owner, allowed visitors, and furniture count
- Rooms tagged
claimablecan be claimed with theclaimcommand, which records ownership (it does not lock the exit) - HouseAccessSystem enforces the ACL: it watches
RoomEnterEventand bounces any player who is not the owner or an allowed visitor back out (a reactive, post-move bounce-back —ExitInfo.key_idis not an access mechanism) furnishcreates furniture entities and places them in the room viaworld.place_entity_in_room()notify_mutation()marks changed components dirty for persistence- The
allowcommand adds player UUIDs toallowed_visitors, whichHouseAccessSystemreads
Variations¶
- Rent system: Charge gold per game-day using a
RecurringTimerSystem; evict if the player can't pay - Upgrades: Track a
house_tierand unlock moremax_furnitureslots at higher tiers - Storage chest: Add a container entity with its own
InventoryComponentfor item storage - Neighborhood bonuses: Grant buffs when multiple houses in an area are claimed
- Hard pre-move gate: Enter the house via a dedicated
enter <house>command guarded by a customis_owner_or_visitor()lock function, so unauthorized players never move at all (instead of the reactive bounce-back)
See Also¶
- Locked Door — Lock expressions and
ExitMetadataComponent - Custom Command — Command creation patterns
- ECS Components — Component design
- Building Commands —
@create,@attribute,@set