pumpkin-api-py/src/pumpkin_api/impl.py
2026-08-18 09:58:31 -07:00

316 lines
11 KiB
Python

# pumpkin-api-py: Pumpkin plugin API for Python
# Copyright (C) 2024 alex
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import traceback
from typing import Any, Callable, Dict, Optional, Type, TypedDict
import wit_world
import wit_world.exports
from componentize_py_types import Err
from wit_world.exports import metadata
from wit_world.imports import command, context, event, scheduler, server, text, world
class AiGoal(TypedDict):
can_start: Callable[[server.Server, world.Entity], bool]
should_continue: Callable[[server.Server, world.Entity], bool]
start: Callable[[server.Server, world.Entity], None]
tick: Callable[[server.Server, world.Entity], None]
stop: Callable[[server.Server, world.Entity], None]
_PLUGIN_INSTANCE: Optional["Plugin"] = None
_EVENT_HANDLERS: Dict[int, Callable[[server.Server, Any], Any]] = {}
_COMMAND_HANDLERS: Dict[
int, Callable[[command.CommandSender, server.Server, command.ConsumedArgs], int]
] = {}
_TASK_HANDLERS: Dict[int, Callable[[server.Server], None]] = {}
_AI_GOALS: Dict[int, AiGoal] = {}
_GENERATORS: Dict[int, Callable[[world.GenerationPhase, world.ChunkBuffer], None]] = {}
_NEXT_HANDLER_ID: int = 0
def _exception_message(exc: Exception) -> str:
if hasattr(exc, "value"):
value = getattr(exc, "value")
if value is not None:
return str(value)
message = str(exc)
if message:
return message
return "".join(traceback.format_exception_only(type(exc), exc)).strip()
def _format_exception(exc: Exception) -> str:
details = "".join(
traceback.format_exception(type(exc), exc, exc.__traceback__)
).strip()
if details:
return details
return _exception_message(exc)
def _get_next_handler_id() -> int:
global _NEXT_HANDLER_ID
handler_id = _NEXT_HANDLER_ID
_NEXT_HANDLER_ID += 1
return handler_id
class Plugin:
def __init__(self):
self._pending_events = []
def metadata(self) -> metadata.PluginMetadata:
raise NotImplementedError
def on_load(self, ctx: context.Context) -> None:
for event_type, handler, priority, blocking in self._pending_events:
self.register_event(ctx, event_type, handler, priority, blocking)
def on_unload(self, ctx: context.Context) -> None:
pass
def register_event(
self,
ctx: context.Context,
event_type: event.EventType,
handler: Callable[[server.Server, Any], Any],
priority=event.EventPriority.NORMAL,
blocking=True,
):
handler_id = _get_next_handler_id()
_EVENT_HANDLERS[handler_id] = handler
ctx.register_event(handler_id, event_type, priority, blocking)
def register_handler(
self,
handler: Callable[
[command.CommandSender, server.Server, command.ConsumedArgs], int
],
) -> int:
handler_id = _get_next_handler_id()
_COMMAND_HANDLERS[handler_id] = handler
return handler_id
def register_command(
self,
ctx: context.Context,
cmd: command.Command,
handler: Callable[
[command.CommandSender, server.Server, command.ConsumedArgs], int
],
permission: str = "",
extra_nodes=None,
):
handler_id = self.register_handler(handler)
cmd.execute_with_handler_id(handler_id)
if extra_nodes:
for node in extra_nodes:
node.execute_with_handler_id(handler_id)
ctx.register_command(cmd, permission)
def register_ai_goal(self, goal: AiGoal) -> int:
goal_id = _get_next_handler_id()
_AI_GOALS[goal_id] = goal
return goal_id
def register_chunk_generator(
self, generator: Callable[[world.GenerationPhase, world.ChunkBuffer], None]
) -> int:
generator_id = _get_next_handler_id()
_GENERATORS[generator_id] = generator
return generator_id
def schedule_delayed_task(
self, delay_ticks: int, handler: Callable[[server.Server], None]
) -> int:
handler_id = _get_next_handler_id()
_TASK_HANDLERS[handler_id] = handler
return scheduler.schedule_delayed_task(handler_id, delay_ticks)
def schedule_repeating_task(
self,
delay_ticks: int,
period_ticks: int,
handler: Callable[[server.Server], None],
) -> int:
handler_id = _get_next_handler_id()
_TASK_HANDLERS[handler_id] = handler
return scheduler.schedule_repeating_task(handler_id, delay_ticks, period_ticks)
def event_handler(
self,
event_type: event.EventType,
priority=event.EventPriority.NORMAL,
blocking=True,
):
def decorator(func):
self._pending_events.append((event_type, func, priority, blocking))
return func
return decorator
def handle_ipc_message(self, sender: str, message: bytes) -> bytes:
raise NotImplementedError
def register_plugin(plugin_class: Type[Plugin]):
global _PLUGIN_INSTANCE
_PLUGIN_INSTANCE = plugin_class()
class WitWorldImpl(wit_world.WitWorld):
def init_plugin(self) -> None:
pass
def on_load(self, context: context.Context) -> None:
if _PLUGIN_INSTANCE:
try:
# In WIT, on-load returns result<_, string>
# componentize-py handles this by expecting None or raising Err
_PLUGIN_INSTANCE.on_load(context)
except Err as e:
raise Err(_exception_message(e))
except Exception as e:
raise Err(_format_exception(e))
def on_unload(self, context: context.Context) -> None:
if _PLUGIN_INSTANCE:
try:
_PLUGIN_INSTANCE.on_unload(context)
except Err as e:
raise Err(_exception_message(e))
except Exception as e:
raise Err(_format_exception(e))
def handle_event(
self, event_id: int, server: server.Server, event: event.Event
) -> event.Event:
if event_id in _EVENT_HANDLERS:
# Pass the inner event data to the handler
result = _EVENT_HANDLERS[event_id](server, event.value)
# If the handler returns something, assume it's the updated event data
if result is not None:
event.value = result
return event
def handle_command(
self,
command_id: int,
sender: command.CommandSender,
server: server.Server,
args: command.ConsumedArgs,
) -> int:
if command_id in _COMMAND_HANDLERS:
try:
return _COMMAND_HANDLERS[command_id](sender, server, args)
except Exception as e:
if hasattr(e, "value") and isinstance(
e.value,
(
command.CommandError_InvalidConsumption,
command.CommandError_InvalidRequirement,
command.CommandError_PermissionDenied,
command.CommandError_CommandFailed,
),
):
raise e
raise Err(
command.CommandError_CommandFailed(
text.TextComponent.text(_format_exception(e))
)
)
raise Err(
command.CommandError_CommandFailed(
text.TextComponent.text(
f"no handler registered for command id {command_id}"
)
)
)
def handle_task(self, handler_id: int, server: server.Server) -> None:
if handler_id in _TASK_HANDLERS:
_TASK_HANDLERS[handler_id](server)
def handle_ipc_message(self, sender: str, message: bytes) -> bytes:
if _PLUGIN_INSTANCE:
try:
return _PLUGIN_INSTANCE.handle_ipc_message(sender, message)
except Err as e:
raise Err(_exception_message(e))
except Exception as e:
raise Err(_format_exception(e))
raise Err("Internal error: No plugin instance available?")
def handle_ai_goal_can_start(
self, goal_id: int, server: server.Server, entity: world.Entity
) -> bool:
if goal_id in _AI_GOALS:
return _AI_GOALS[goal_id]["can_start"](server, entity)
raise Exception(f"No AI goal registered for ID {goal_id}")
def handle_ai_goal_should_continue(
self, goal_id: int, server: server.Server, entity: world.Entity
) -> bool:
if goal_id in _AI_GOALS:
return _AI_GOALS[goal_id]["should_continue"](server, entity)
raise Exception(f"No AI goal registered for ID {goal_id}")
def handle_ai_goal_start(
self, goal_id: int, server: server.Server, entity: world.Entity
) -> None:
if goal_id in _AI_GOALS:
_AI_GOALS[goal_id]["start"](server, entity)
return
raise Exception(f"No AI goal registered for ID {goal_id}")
def handle_ai_goal_tick(
self, goal_id: int, server: server.Server, entity: world.Entity
) -> None:
if goal_id in _AI_GOALS:
_AI_GOALS[goal_id]["tick"](server, entity)
return
raise Exception(f"No AI goal registered for ID {goal_id}")
def handle_ai_goal_stop(
self, goal_id: int, server: server.Server, entity: world.Entity
) -> None:
if goal_id in _AI_GOALS:
_AI_GOALS[goal_id]["stop"](server, entity)
return
raise Exception(f"No AI goal registered for ID {goal_id}")
def handle_generate_phase(
self, generator_id: int, phase: world.GenerationPhase, chunk: world.ChunkBuffer
) -> None:
if generator_id in _AI_GOALS:
_GENERATORS[generator_id](phase, chunk)
return
raise Exception(f"No generator registered for ID {generator_id}")
class MetadataImpl:
def get_metadata(self) -> metadata.PluginMetadata:
if _PLUGIN_INSTANCE:
return _PLUGIN_INSTANCE.metadata()
return metadata.PluginMetadata("unknown", "0.0.0", [], "No metadata", [], [])