diff --git a/multi_user_bridge.py b/multi_user_bridge.py index fbefa972..dd0aa32b 100644 --- a/multi_user_bridge.py +++ b/multi_user_bridge.py @@ -33,6 +33,131 @@ from pathlib import Path from datetime import datetime from typing import Optional +# ── Plugin System ────────────────────────────────────────────────────── + +class Plugin: + """Base class for bridge plugins. Override methods to add game mechanics.""" + + name: str = "unnamed" + description: str = "" + + def on_message(self, user_id: str, message: str, room: str) -> str | None: + """Called on every chat message. Return a string to override the response, + or None to let the normal AI handle it.""" + return None + + def on_join(self, user_id: str, room: str) -> str | None: + """Called when a player joins a room. Return a message to broadcast, or None.""" + return None + + def on_leave(self, user_id: str, room: str) -> str | None: + """Called when a player leaves a room. Return a message to broadcast, or None.""" + return None + + def on_command(self, user_id: str, command: str, args: str, room: str) -> dict | None: + """Called on MUD commands. Return a result dict to override command handling, + or None to let the default parser handle it.""" + return None + + +class PluginRegistry: + """Registry that manages plugin lifecycle and dispatches hooks.""" + + def __init__(self): + self._plugins: dict[str, Plugin] = {} + self._lock = threading.Lock() + + def register(self, plugin: Plugin): + """Register a plugin by its name.""" + with self._lock: + self._plugins[plugin.name] = plugin + print(f"[PluginRegistry] Registered plugin: {plugin.name}") + + def unregister(self, name: str) -> bool: + """Unregister a plugin by name.""" + with self._lock: + if name in self._plugins: + del self._plugins[name] + print(f"[PluginRegistry] Unregistered plugin: {name}") + return True + return False + + def get(self, name: str) -> Plugin | None: + """Get a plugin by name.""" + return self._plugins.get(name) + + def list_plugins(self) -> list[dict]: + """List all registered plugins.""" + return [ + {"name": p.name, "description": p.description} + for p in self._plugins.values() + ] + + def fire_on_message(self, user_id: str, message: str, room: str) -> str | None: + """Fire on_message hooks. First non-None return wins.""" + for plugin in self._plugins.values(): + result = plugin.on_message(user_id, message, room) + if result is not None: + return result + return None + + def fire_on_join(self, user_id: str, room: str) -> str | None: + """Fire on_join hooks. Collect all non-None returns.""" + messages = [] + for plugin in self._plugins.values(): + result = plugin.on_join(user_id, room) + if result is not None: + messages.append(result) + return "\n".join(messages) if messages else None + + def fire_on_leave(self, user_id: str, room: str) -> str | None: + """Fire on_leave hooks. Collect all non-None returns.""" + messages = [] + for plugin in self._plugins.values(): + result = plugin.on_leave(user_id, room) + if result is not None: + messages.append(result) + return "\n".join(messages) if messages else None + + def fire_on_command(self, user_id: str, command: str, args: str, room: str) -> dict | None: + """Fire on_command hooks. First non-None return wins.""" + for plugin in self._plugins.values(): + result = plugin.on_command(user_id, command, args, room) + if result is not None: + return result + return None + + def load_from_directory(self, plugin_dir: str): + """Auto-load all Python files in a directory as plugins.""" + plugin_path = Path(plugin_dir) + if not plugin_path.is_dir(): + print(f"[PluginRegistry] Plugin directory not found: {plugin_dir}") + return + + for py_file in plugin_path.glob("*.py"): + if py_file.name.startswith("_"): + continue + try: + module_name = f"plugins.{py_file.stem}" + spec = __import__("importlib").util.spec_from_file_location(module_name, py_file) + module = __import__("importlib").util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Look for Plugin subclasses in the module + for attr_name in dir(module): + attr = getattr(module, attr_name) + if (isinstance(attr, type) + and issubclass(attr, Plugin) + and attr is not Plugin): + plugin_instance = attr() + self.register(plugin_instance) + + except Exception as e: + print(f"[PluginRegistry] Failed to load {py_file.name}: {e}") + + +plugin_registry = PluginRegistry() + # ── Crisis Protocol ──────────────────────────────────────────────────── CRISIS_PROTOCOL = """ @@ -604,9 +729,15 @@ class BridgeHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == '/bridge/health': + state_file = WORLD_DIR / 'world_state.json' + ws = json.loads(state_file.read_text()) if state_file.exists() else {} self._json_response({ "status": "ok", "active_sessions": session_manager.get_session_count(), + "tick": ws.get("tick", 0), + "time_of_day": ws.get("time_of_day"), + "weather": ws.get("weather"), + "world_tick_running": world_tick_system._running, "timestamp": datetime.now().isoformat(), }) elif self.path == '/bridge/sessions': @@ -758,7 +889,17 @@ class BridgeHandler(BaseHTTPRequestHandler): room, "player_join", f"{username} has entered {room}.", exclude_user=user_id, data=enter_event) - response = session.chat(message) + # Plugin hook: on_join + plugin_msg = plugin_registry.fire_on_join(user_id, room) + if plugin_msg: + notification_manager.notify(user_id, "plugin", plugin_msg, room=room, username=username) + + # Plugin hook: on_message (can override response) + plugin_override = plugin_registry.fire_on_message(user_id, message, room) + if plugin_override is not None: + response = plugin_override + else: + response = session.chat(message) # Auto-notify: crisis detection — scan response for crisis protocol keywords crisis_keywords = ["988", "741741", "safe right now", "crisis", "Crisis Text Line"] @@ -797,6 +938,12 @@ class BridgeHandler(BaseHTTPRequestHandler): old_room, "player_leave", f"{session.username} has left {old_room}.", exclude_user=user_id, data=leave_event) + # Plugin hook: on_leave + plugin_leave = plugin_registry.fire_on_leave(user_id, old_room) + if plugin_leave: + notification_manager.broadcast_room( + old_room, "plugin", plugin_leave, + exclude_user=user_id) # Enter new room session.room = new_room enter_event = presence_manager.enter_room(user_id, session.username, new_room) @@ -1049,6 +1196,187 @@ class BridgeHandler(BaseHTTPRequestHandler): pass # Suppress HTTP logs +# ── World Tick System ────────────────────────────────────────────────── + +import random + +class WorldTickSystem: + """Advances the world state every TICK_INTERVAL seconds. + + On each tick: + - Increment tick counter + - Cycle time_of_day (dawn -> morning -> midday -> afternoon -> dusk -> night) + - Randomly change weather (clear, cloudy, foggy, light rain, heavy rain, storm) + - Evolve room states: + * The Forge: fire decays (blazing -> burning -> glowing -> embers -> cold) + * The Garden: growth advances (seeds -> sprouts -> growing -> blooming -> harvest) + * The Bridge: rain toggles + - Broadcast world events to connected players + - Save world state to disk + """ + + TICK_INTERVAL = 60 # seconds + + TIME_CYCLE = ["dawn", "morning", "midday", "afternoon", "dusk", "night"] + + WEATHER_OPTIONS = [ + "clear", "clear", "clear", # weighted toward clear + "cloudy", "cloudy", + "foggy", + "light rain", + "heavy rain", + "storm", + ] + + FIRE_STAGES = ["cold", "embers", "glowing", "burning", "blazing"] + GROWTH_STAGES = ["seeds", "sprouts", "growing", "blooming", "harvest"] + + def __init__(self): + self._thread: threading.Thread | None = None + self._running = False + self._state_lock = threading.Lock() + + # ── lifecycle ─────────────────────────────────────────────────────── + + def start(self): + """Start the tick loop in a daemon thread.""" + if self._running: + return + self._running = True + self._thread = threading.Thread(target=self._loop, daemon=True, name="world-tick") + self._thread.start() + print("[WorldTick] Started — ticking every {}s".format(self.TICK_INTERVAL)) + + def stop(self): + self._running = False + + # ── internal loop ─────────────────────────────────────────────────── + + def _loop(self): + while self._running: + time.sleep(self.TICK_INTERVAL) + try: + self._tick() + except Exception as e: + print(f"[WorldTick] Error during tick: {e}") + + def _tick(self): + """Execute one world tick.""" + state_file = WORLD_DIR / 'world_state.json' + if state_file.exists(): + state = json.loads(state_file.read_text()) + else: + state = {"tick": 0, "time_of_day": "morning", "weather": "clear", "rooms": {}} + + events: list[str] = [] + + with self._state_lock: + # 1. Advance tick counter + state["tick"] = state.get("tick", 0) + 1 + tick_num = state["tick"] + + # 2. Time of day — advance every tick + current_time = state.get("time_of_day", "morning") + try: + idx = self.TIME_CYCLE.index(current_time) + except ValueError: + idx = 0 + next_idx = (idx + 1) % len(self.TIME_CYCLE) + new_time = self.TIME_CYCLE[next_idx] + state["time_of_day"] = new_time + if new_time == "dawn": + events.append("The sun rises over The Tower. A new day begins.") + elif new_time == "dusk": + events.append("The sky darkens. Dusk settles over the world.") + elif new_time == "night": + events.append("Night falls. The green LED in The Tower pulses in the dark.") + + # 3. Weather — change every 3 ticks + if tick_num % 3 == 0: + old_weather = state.get("weather", "clear") + new_weather = random.choice(self.WEATHER_OPTIONS) + state["weather"] = new_weather + if new_weather != old_weather: + events.append(f"The weather shifts to {new_weather}.") + + # 4. Room states + rooms = state.get("rooms", {}) + + # The Forge — fire decays one stage every 2 ticks unless rekindled + forge = rooms.get("The Forge", {}) + if forge: + fire = forge.get("fire_state", "cold") + untouched = forge.get("fire_untouched_ticks", 0) + 1 + forge["fire_untouched_ticks"] = untouched + if untouched >= 2 and fire != "cold": + try: + fi = self.FIRE_STAGES.index(fire) + except ValueError: + fi = 0 + if fi > 0: + forge["fire_state"] = self.FIRE_STAGES[fi - 1] + forge["fire_untouched_ticks"] = 0 + if forge["fire_state"] == "embers": + events.append("The forge fire has died down to embers.") + elif forge["fire_state"] == "cold": + events.append("The forge fire has gone cold.") + rooms["The Forge"] = forge + + # The Garden — growth advances one stage every 4 ticks + garden = rooms.get("The Garden", {}) + if garden: + if tick_num % 4 == 0: + growth = garden.get("growth_stage", "seeds") + try: + gi = self.GROWTH_STAGES.index(growth) + except ValueError: + gi = 0 + if gi < len(self.GROWTH_STAGES) - 1: + garden["growth_stage"] = self.GROWTH_STAGES[gi + 1] + events.append(f"The garden has advanced to {garden['growth_stage']}.") + else: + # Reset cycle + garden["growth_stage"] = "seeds" + events.append("The garden has been harvested. New seeds are planted.") + rooms["The Garden"] = garden + + # The Bridge — rain state follows weather + bridge = rooms.get("The Bridge", {}) + if bridge: + weather = state.get("weather", "clear") + is_raining = weather in ("light rain", "heavy rain", "storm") + was_raining = bridge.get("rain_active", False) + bridge["rain_active"] = is_raining + if is_raining and not was_raining: + events.append("Rain begins to fall on The Bridge.") + elif not is_raining and was_raining: + events.append("The rain on The Bridge lets up.") + rooms["The Bridge"] = bridge + + state["rooms"] = rooms + state["last_updated"] = datetime.now().isoformat() + + # 5. Save world state + state_file.write_text(json.dumps(state, indent=2)) + + # 6. Broadcast events to all occupied rooms (outside lock) + if events: + event_text = " ".join(events) + for room_name in list(presence_manager._rooms.keys()): + players = presence_manager.get_players_in_room(room_name) + if players: + notification_manager.broadcast_room( + room_name, "world_tick", event_text, + data={"tick": state["tick"], "time_of_day": state["time_of_day"], + "weather": state.get("weather"), "events": events}, + ) + print(f"[WorldTick] tick={state['tick']} time={state['time_of_day']} " + f"weather={state.get('weather')} events={len(events)}") + + +world_tick_system = WorldTickSystem() + + # ── Main ─────────────────────────────────────────────────────────────── def main(): @@ -1069,7 +1397,10 @@ def main(): print(f" POST /bridge/move — Move user to room (triggers presence)") print(f" POST /bridge/command — MUD command parser (look, go, examine, say, ask)") print() - + + # Start world tick system + world_tick_system.start() + server = HTTPServer((BRIDGE_HOST, BRIDGE_PORT), BridgeHandler) server.serve_forever()