Skip to content

Custom Command

Problem

You want to create a brand-new player command from scratch — with argument parsing, permission checks, and proper registration in a content pack.

Solution

Step 1: Define the Command Handler

from maid_engine.commands.decorators import command, arguments
from maid_engine.commands.arguments import (
    ArgumentSpec,
    ArgumentType,
    ParsedArguments,
    SearchScope,
)
from maid_engine.commands import CommandContext, AccessLevel
from maid_stdlib.components import (
    DescriptionComponent,
    HealthComponent,
    InventoryComponent,
)


@command(
    name="heal",
    aliases=["cure"],
    category="skills",
    help_text="Heal yourself or a target with a potion",
    access_level=AccessLevel.PLAYER,
    locks="NOT in_combat()",
)
@arguments(
    ArgumentSpec(
        "target",
        ArgumentType.ENTITY,
        required=False,
        default=None,
        description="Who to heal (default: yourself)",
        search_scope=SearchScope.ROOM,
    ),
)
async def cmd_heal(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Use a healing potion on yourself or an ally."""
    world = ctx.world
    player = world.get_entity(ctx.player_id)
    if not player:
        return False

    # Determine target (self if none specified)
    target_ref = args["target"]
    if target_ref and target_ref.found:
        target = target_ref.entity
    else:
        target = player

    if not target:
        await ctx.session.send("Heal who?\n")
        return False

    # Check for healing potion in inventory
    inv = player.try_get(InventoryComponent)
    if not inv:
        await ctx.session.send("You have no inventory.\n")
        return False

    potion_id = None
    potion_weight = 0.0
    for item_id in inv.items:
        item = world.get_entity(item_id)
        if not item:
            continue
        desc = item.try_get(DescriptionComponent)
        if desc and desc.matches_keyword("healing_potion"):
            from maid_stdlib.components import ItemComponent
            item_comp = item.try_get(ItemComponent)
            potion_weight = item_comp.weight if item_comp else 0.0
            potion_id = item_id
            break

    if not potion_id:
        await ctx.session.send("You don't have any healing potions.\n")
        return False

    # Apply healing
    health = target.try_get(HealthComponent)
    if not health:
        target_desc = target.try_get(DescriptionComponent)
        name = target_desc.name if target_desc else "That"
        await ctx.session.send(f"{name} can't be healed.\n")
        return False

    heal_amount = 25
    actual = health.heal(heal_amount)

    # Consume the potion
    inv.remove_item(potion_id, potion_weight)
    world.destroy_entity(potion_id)

    target_desc = target.try_get(DescriptionComponent)
    name = target_desc.name if target_desc else "them"
    if target == player:
        await ctx.session.send(
            f"You drink a healing potion and recover {actual} HP.\n"
        )
    else:
        await ctx.session.send(
            f"You give a healing potion to {name}. "
            f"They recover {actual} HP.\n"
        )

    return True

Step 2: Register in Your Content Pack

from maid_engine.plugins.protocol import ContentPack
from maid_engine.plugins import ContentPackManifest
from maid_engine.commands.registry import CommandRegistry, LayeredCommandRegistry
from maid_engine.core.world import World
from maid_engine.core.ecs import System
from maid_engine.core.events import Event
from maid_engine.core.engine import GameEngine


class MyContentPack:
    """Example content pack with a custom command."""

    @property
    def manifest(self) -> ContentPackManifest:
        return ContentPackManifest(
            name="my-pack",
            version="1.0.0",
            description="My custom content pack",
        )

    def get_dependencies(self) -> list[str]:
        return ["stdlib"]  # Depends on standard library

    def get_systems(self, world: World) -> list[System]:
        return []

    def get_events(self) -> list[type[Event]]:
        return []

    def register_commands(self, registry: CommandRegistry | LayeredCommandRegistry) -> None:
        registry.register(
            "heal",
            cmd_heal,
            self.manifest.name,
            aliases=["cure"],
            category="skills",
        )

    async def on_load(self, engine: GameEngine) -> None:
        pass

    async def on_unload(self, engine: GameEngine) -> None:
        pass

Step 3: Pattern-Based Command (Alternative)

For commands with complex syntax like give <item> to <target>:

from maid_engine.commands.decorators import command, pattern
from maid_engine.commands.arguments import (
    ArgumentSpec,
    ArgumentType,
    ParsedArguments,
    SearchScope,
)
from maid_engine.commands import CommandContext
from maid_stdlib.components import DescriptionComponent, InventoryComponent, ItemComponent


@command(name="give", category="items", help_text="Give an item to someone")
@pattern(
    "<item> to <target>",
    item=ArgumentSpec(
        "item",
        ArgumentType.ENTITY,
        search_scope=SearchScope.INVENTORY,
    ),
    target=ArgumentSpec(
        "target",
        ArgumentType.ENTITY,
        search_scope=SearchScope.ROOM,
    ),
)
async def cmd_give(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Give an item from your inventory to another entity."""
    item_ref = args["item"]
    target_ref = args["target"]

    if not item_ref.found:
        await ctx.session.send("You don't have that item.\n")
        return False

    if not target_ref.found:
        await ctx.session.send("Give it to whom?\n")
        return False

    # Transfer item between inventories
    giver = ctx.world.get_entity(ctx.player_id)
    giver_inv = giver.get(InventoryComponent)
    target_inv = target_ref.entity.get(InventoryComponent)

    if not target_inv:
        await ctx.session.send("They can't carry items.\n")
        return False

    item_comp = item_ref.entity.try_get(ItemComponent)
    item_weight = item_comp.weight if item_comp else 0.0
    giver_inv.remove_item(item_ref.entity.id, item_weight)
    target_inv.add_item(item_ref.entity.id, item_weight)

    # Get display names from DescriptionComponent
    item_desc = item_ref.entity.try_get(DescriptionComponent)
    item_name = item_desc.name if item_desc else item_ref.keyword
    target_desc = target_ref.entity.try_get(DescriptionComponent)
    target_name = target_desc.name if target_desc else target_ref.keyword

    await ctx.session.send(f"You give {item_name} to {target_name}.\n")
    return True

Step 4: Command with Hooks

Add pre/post execution hooks for cross-cutting concerns:

from maid_engine.commands.hooks import HookPriority, HookResult, PreHookContext, PostHookContext


async def cooldown_pre_hook(ctx: PreHookContext) -> HookResult:
    """Block command if on cooldown. Return CANCEL to block."""
    # Check cooldown logic...
    if False:  # Replace with actual cooldown check
        ctx.cancel("That command is on cooldown!")
        return HookResult.CANCEL
    return HookResult.CONTINUE  # Allow execution


async def log_post_hook(ctx: PostHookContext) -> None:
    """Log command usage after execution."""
    # ctx.result contains the handler return value
    # ctx.execution_time_ms contains timing info
    pass


# In register_commands:
def register_commands(self, registry: LayeredCommandRegistry) -> None:
    registry.register("heal", cmd_heal, "my-pack")
    registry.register_pre_hook(
        "heal_cooldown", cooldown_pre_hook, HookPriority.NORMAL
    )
    registry.register_post_hook(
        "heal_log", log_post_hook, HookPriority.LOW
    )

How It Works

  1. @command decorator attaches metadata (name, aliases, locks, access level) to the function
  2. @arguments decorator wraps the handler with automatic argument parsing and validation
  3. @pattern decorator parses structured input like "X to Y" into named arguments
  4. CommandContext provides access to world, session, player_id, and raw input
  5. ParsedArguments gives dict-like access to parsed and validated argument values
  6. registry.register() in register_commands() adds the command to the layered registry
  7. Lock expressions like NOT in_combat() are checked automatically before the handler runs
  8. Return True if the command succeeded, False if it failed

Argument Type Quick Reference

Type Resolves To Example Input
STRING str "hello world"
INTEGER int "42"
FLOAT float "3.14"
BOOLEAN bool "yes", "true", "on"
ENTITY EntityReference "goblin", "2.sword"
ENTITY_LIST EntityReference (multiple) "all.potion"
DIRECTION str (normalized) "n", "north", "ne"
PLAYER EntityReference "PlayerName"
REST str (remaining input) anything after other args

Variations

  • Admin command: Set access_level=AccessLevel.ADMIN to restrict to admins
  • Subcommands: Parse ctx.args[0] to dispatch to sub-handlers (heal self, heal other)
  • Confirmation: Store pending state and require a second command to confirm (heal --confirm)
  • Target self shortcut: Check if ctx.args and ctx.args[0] == "self" as a convenience alias

See Also