diff --git a/multi_user_bridge.py b/multi_user_bridge.py index a352e31f..2c48cc53 100644 --- a/multi_user_bridge.py +++ b/multi_user_bridge.py @@ -712,8 +712,382 @@ class InventoryManager: inventory_manager = InventoryManager() +# ── Combat System ────────────────────────────────────────────────────── + +COMBAT_FILE = WORLD_DIR / 'combat.json' + +class NPC: + """A non-player character that can be fought.""" + + def __init__(self, npc_id: str, name: str, room: str, description: str, + health: int, max_health: int, attack: int, defense: int, + loot: list[dict], respawn_ticks: int = 5): + self.npc_id = npc_id + self.name = name + self.room = room + self.description = description + self.health = health + self.max_health = max_health + self.attack = attack + self.defense = defense + self.loot = loot # [{name, description, drop_chance}] + self.respawn_ticks = respawn_ticks + self.alive = True + self._dead_tick: int | None = None + + def to_dict(self) -> dict: + return { + "npc_id": self.npc_id, + "name": self.name, + "room": self.room, + "description": self.description, + "health": self.health, + "max_health": self.max_health, + "attack": self.attack, + "defense": self.defense, + "loot": self.loot, + "alive": self.alive, + "dead_tick": self._dead_tick, + "respawn_ticks": self.respawn_ticks, + } + + @classmethod + def from_dict(cls, data: dict) -> 'NPC': + npc = cls( + data["npc_id"], data["name"], data["room"], data["description"], + data["health"], data["max_health"], data["attack"], data["defense"], + data["loot"], data.get("respawn_ticks", 5), + ) + npc.alive = data.get("alive", True) + npc._dead_tick = data.get("dead_tick") + return npc + + def health_bar(self) -> str: + """Return a text health bar like [████████░░] 80/100.""" + width = 10 + filled = int((self.health / self.max_health) * width) if self.max_health > 0 else 0 + bar = "█" * filled + "░" * (width - filled) + return f"[{bar}] {self.health}/{self.max_health}" + + def die(self, tick: int): + """Mark NPC as dead.""" + self.alive = False + self.health = 0 + self._dead_tick = tick + + def respawn(self): + """Respawn the NPC at full health.""" + self.alive = True + self.health = self.max_health + self._dead_tick = None + + +class CombatEncounter: + """An active fight between a player and an NPC.""" + + def __init__(self, user_id: str, username: str, room: str, npc_id: str): + self.user_id = user_id + self.username = username + self.room = room + self.npc_id = npc_id + self.player_hp = 100 + self.player_max_hp = 100 + self.player_defending = False + self.log: list[str] = [] + self.active = True + self.created_at = datetime.now().isoformat() + + def to_dict(self) -> dict: + return { + "user_id": self.user_id, + "username": self.username, + "room": self.room, + "npc_id": self.npc_id, + "player_hp": self.player_hp, + "player_max_hp": self.player_max_hp, + "player_defending": self.player_defending, + "log": self.log, + "active": self.active, + } + + +class CombatManager: + """Manages NPCs, encounters, and combat resolution.""" + + def __init__(self): + self._npcs: dict[str, NPC] = {} # npc_id -> NPC + self._encounters: dict[str, CombatEncounter] = {} # user_id -> encounter + self._counter = 0 + self._lock = threading.Lock() + + # ── NPC management ────────────────────────────────────────────── + + def create_npc(self, name: str, room: str, description: str, + health: int, attack: int, defense: int, + loot: list[dict], respawn_ticks: int = 5) -> NPC: + with self._lock: + self._counter += 1 + npc_id = f"npc_{self._counter}" + npc = NPC(npc_id, name, room, description, + health, health, attack, defense, loot, respawn_ticks) + self._npcs[npc_id] = npc + self._save() + return npc + + def get_npc(self, npc_id: str) -> NPC | None: + return self._npcs.get(npc_id) + + def get_npcs_in_room(self, room: str) -> list[NPC]: + with self._lock: + return [n for n in self._npcs.values() if n.room == room and n.alive] + + def get_all_npcs(self) -> list[dict]: + with self._lock: + return [n.to_dict() for n in self._npcs.values()] + + def check_respawns(self, current_tick: int): + """Called each world tick — respawn NPCs whose timer has elapsed.""" + with self._lock: + for npc in self._npcs.values(): + if not npc.alive and npc._dead_tick is not None: + if current_tick - npc._dead_tick >= npc.respawn_ticks: + npc.respawn() + self._save() + + # ── Encounter lifecycle ───────────────────────────────────────── + + def start_fight(self, user_id: str, username: str, room: str, npc_id: str) -> dict: + """Begin combat with an NPC.""" + with self._lock: + if user_id in self._encounters and self._encounters[user_id].active: + return {"error": "You are already in a fight! Attack or defend."} + npc = self._npcs.get(npc_id) + if not npc: + return {"error": f"NPC '{npc_id}' not found."} + if not npc.alive: + return {"error": f"{npc.name} is dead. It will respawn later."} + if npc.room != room: + return {"error": f"{npc.name} is not in this room."} + encounter = CombatEncounter(user_id, username, room, npc_id) + encounter.log.append(f"Combat started! {username} vs {npc.name}.") + self._encounters[user_id] = encounter + return {"ok": True, "encounter": encounter.to_dict(), "npc": npc.to_dict()} + + def attack(self, user_id: str) -> dict: + """Player attacks NPC; NPC counter-attacks.""" + with self._lock: + enc = self._encounters.get(user_id) + if not enc or not enc.active: + return {"error": "No active fight. Start one first."} + npc = self._npcs.get(enc.npc_id) + if not npc or not npc.alive: + enc.active = False + return {"error": "The enemy is already dead."} + + import random + + # Player attack + player_dmg = max(1, random.randint(8, 16) - npc.defense // 3) + npc.health -= player_dmg + enc.log.append(f"You strike {npc.name} for {player_dmg} damage.") + + npc_dmg = 0 + if npc.health > 0: + # NPC counter-attack + base_npc_dmg = random.randint(npc.attack // 2, npc.attack) + if enc.player_defending: + npc_dmg = max(1, base_npc_dmg // 2) + enc.log.append(f"{npc.name} attacks for {npc_dmg} (blocked — half damage).") + else: + npc_dmg = max(1, base_npc_dmg - random.randint(0, 4)) + enc.log.append(f"{npc.name} attacks you for {npc_dmg} damage.") + enc.player_hp -= npc_dmg + else: + npc.die(self._get_tick()) + loot_dropped = self._roll_loot(npc) + enc.log.append(f"{npc.name} is slain!") + if loot_dropped: + for item in loot_dropped: + inventory_manager.add_room_item(enc.room, item["name"], item["description"], dropped_by=npc.name) + enc.log.append(f"Loot dropped: {item['name']}") + enc.active = False + self._save() + + enc.player_defending = False + + # Check player death + if enc.player_hp <= 0: + enc.player_hp = 0 + enc.log.append("You have been defeated!") + enc.active = False + + return {"ok": True, "encounter": enc.to_dict(), "npc": npc.to_dict(), + "player_dmg": player_dmg, "npc_dmg": npc_dmg, + "loot": loot_dropped if not npc.alive else []} + + def defend(self, user_id: str) -> dict: + """Player defends — next NPC attack does half damage.""" + with self._lock: + enc = self._encounters.get(user_id) + if not enc or not enc.active: + return {"error": "No active fight."} + npc = self._npcs.get(enc.npc_id) + if not npc or not npc.alive: + enc.active = False + return {"error": "The enemy is already dead."} + + import random + + enc.player_defending = True + enc.log.append("You brace for the next attack (defending).") + + # NPC still attacks + base_npc_dmg = random.randint(npc.attack // 2, npc.attack) + npc_dmg = max(1, base_npc_dmg // 2) + enc.log.append(f"{npc.name} attacks for {npc_dmg} (blocked — half damage).") + enc.player_hp -= npc_dmg + + if enc.player_hp <= 0: + enc.player_hp = 0 + enc.log.append("You have been defeated!") + enc.active = False + + return {"ok": True, "encounter": enc.to_dict(), "npc": npc.to_dict(), "npc_dmg": npc_dmg} + + def flee(self, user_id: str) -> dict: + """Player flees combat.""" + with self._lock: + enc = self._encounters.get(user_id) + if not enc or not enc.active: + return {"error": "No active fight."} + enc.active = False + enc.log.append("You flee from combat!") + return {"ok": True, "encounter": enc.to_dict()} + + def get_encounter(self, user_id: str) -> CombatEncounter | None: + return self._encounters.get(user_id) + + def get_room_combat_status(self, room: str) -> list[dict]: + """Get all active fights and NPC health in a room (for health bars).""" + with self._lock: + status = [] + # NPCs + for npc in self._npcs.values(): + if npc.room == room: + status.append({ + "type": "npc", + "name": npc.name, + "npc_id": npc.npc_id, + "alive": npc.alive, + "health_bar": npc.health_bar() if npc.alive else "[dead]", + "health": npc.health, + "max_health": npc.max_health, + }) + # Active player fights + for enc in self._encounters.values(): + if enc.room == room and enc.active: + status.append({ + "type": "player", + "username": enc.username, + "user_id": enc.user_id, + "hp": enc.player_hp, + "max_hp": enc.player_max_hp, + "fighting": enc.npc_id, + }) + return status + + def _roll_loot(self, npc: NPC) -> list[dict]: + """Roll for loot drops.""" + import random + dropped = [] + for item in npc.loot: + chance = item.get("drop_chance", 1.0) + if random.random() < chance: + dropped.append({"name": item["name"], "description": item["description"]}) + return dropped + + def _get_tick(self) -> int: + """Read current world tick.""" + state_file = WORLD_DIR / 'world_state.json' + if state_file.exists(): + try: + state = json.loads(state_file.read_text()) + return state.get("tick", 0) + except Exception: + return 0 + return 0 + + # ── Persistence ───────────────────────────────────────────────── + + def _save(self): + try: + data = { + "counter": self._counter, + "npcs": {nid: n.to_dict() for nid, n in self._npcs.items()}, + } + COMBAT_FILE.parent.mkdir(parents=True, exist_ok=True) + COMBAT_FILE.write_text(json.dumps(data, indent=2)) + except Exception as e: + print(f"[CombatManager] Save failed: {e}") + + def load(self): + if not COMBAT_FILE.exists(): + self._seed_npcs() + return + try: + data = json.loads(COMBAT_FILE.read_text()) + self._counter = data.get("counter", 0) + for nid, ndata in data.get("npcs", {}).items(): + self._npcs[nid] = NPC.from_dict(ndata) + print(f"[CombatManager] Loaded {len(self._npcs)} NPCs.") + except Exception as e: + print(f"[CombatManager] Load failed: {e}") + self._seed_npcs() + + def _seed_npcs(self): + """Seed starter NPCs.""" + self.create_npc( + "Shadow Wraith", "The Bridge", + "A swirling mass of darkness with glowing eyes. It haunts the bridge, " + "feeding on the fears of those who cross.", + health=60, attack=14, defense=4, + loot=[ + {"name": "Wraith Essence", "description": "A vial of shimmering dark energy.", "drop_chance": 0.8}, + {"name": "Shadow Cloak", "description": "A cloak woven from pure shadow.", "drop_chance": 0.3}, + ], + respawn_ticks=5, + ) + self.create_npc( + "Iron Golem", "The Forge", + "A hulking automaton of iron and fire. It guards the forge with tireless vigilance, " + "its joints grinding with every step.", + health=100, attack=10, defense=10, + loot=[ + {"name": "Golem Core", "description": "A warm crystal that powered the golem.", "drop_chance": 0.7}, + {"name": "Iron Shard", "description": "A shard of enchanted iron, still warm.", "drop_chance": 0.5}, + ], + respawn_ticks=7, + ) + self.create_npc( + "Garden Serpent", "The Garden", + "A massive vine serpent camouflaged among the herbs. Its fangs drip with " + "a sickly green venom.", + health=45, attack=18, defense=2, + loot=[ + {"name": "Serpent Fang", "description": "A curved fang, still coated in venom.", "drop_chance": 0.9}, + {"name": "Enchanted Vine", "description": "A living vine that moves on its own.", "drop_chance": 0.4}, + ], + respawn_ticks=4, + ) + print(f"[CombatManager] Seeded {len(self._npcs)} NPCs.") + + +combat_manager = CombatManager() + # ── Session Management ───────────────────────────────────────────────── +combat_manager.load() + class UserSession: """Isolated conversation context for one user.""" @@ -1238,9 +1612,17 @@ class BridgeHandler(BaseHTTPRequestHandler): self._json_response(saved) else: self._json_response({"error": "no session or saved summary"}, 404) + elif self.path.startswith('/bridge/combat/status/'): + # GET /bridge/combat/status/ — NPC health bars + active fights in room + room = self.path.split('/bridge/combat/status/')[-1].rstrip('/') + status = combat_manager.get_room_combat_status(room) + self._json_response({"room": room, "combat_status": status}) + elif self.path == '/bridge/combat/npcs': + # GET /bridge/combat/npcs — list all NPCs + self._json_response({"npcs": combat_manager.get_all_npcs()}) else: self._json_response({"error": "not found"}, 404) - + def do_POST(self): content_length = int(self.headers.get('Content-Length', 0)) body = json.loads(self.rfile.read(content_length)) if content_length else {} @@ -1447,6 +1829,91 @@ class BridgeHandler(BaseHTTPRequestHandler): exclude_user=user_id, data=result) self._json_response(result) + elif self.path == '/bridge/combat/start': + # POST /bridge/combat/start — begin fight with NPC + # Body: { user_id, username, room, npc_id } + user_id = body.get('user_id', 'anonymous') + username = body.get('username', 'Anonymous') + room = body.get('room', 'The Threshold') + npc_id = body.get('npc_id', '') + if not npc_id: + self._json_response({"error": "npc_id required"}, 400) + return + result = combat_manager.start_fight(user_id, username, room, npc_id) + if "error" in result: + self._json_response(result, 400) + else: + npc = result.get("npc", {}) + notification_manager.broadcast_room( + room, "combat_start", + f"{username} has engaged {npc.get('name', 'an enemy')} in combat!", + exclude_user=user_id, data=result) + self._json_response(result) + + elif self.path == '/bridge/combat/attack': + # POST /bridge/combat/attack — attack NPC in active fight + # Body: { user_id, username, room } + user_id = body.get('user_id', 'anonymous') + username = body.get('username', 'Anonymous') + room = body.get('room', 'The Threshold') + result = combat_manager.attack(user_id) + if "error" in result: + self._json_response(result, 400) + else: + enc = result.get("encounter", {}) + npc = result.get("npc", {}) + loot = result.get("loot", []) + # Broadcast combat action to room + combat_msg = f"{username} strikes {npc.get('name', 'the enemy')} for {result.get('player_dmg', 0)} damage!" + notification_manager.broadcast_room( + room, "combat_action", combat_msg, + exclude_user=user_id, data=result) + # If NPC died, broadcast death + if not npc.get("alive", True): + death_msg = f"{npc.get('name', 'The enemy')} has been slain by {username}!" + if loot: + death_msg += f" Loot: {', '.join(i['name'] for i in loot)}" + notification_manager.broadcast_room( + room, "combat_death", death_msg, data=result) + # If player died + if enc.get("player_hp", 1) <= 0: + notification_manager.broadcast_room( + room, "combat_defeat", + f"{username} has been defeated in combat!", data=result) + self._json_response(result) + + elif self.path == '/bridge/combat/defend': + # POST /bridge/combat/defend — defend (half damage next hit) + # Body: { user_id, username, room } + user_id = body.get('user_id', 'anonymous') + username = body.get('username', 'Anonymous') + room = body.get('room', 'The Threshold') + result = combat_manager.defend(user_id) + if "error" in result: + self._json_response(result, 400) + else: + notification_manager.broadcast_room( + room, "combat_action", + f"{username} braces defensively.", + exclude_user=user_id, data=result) + self._json_response(result) + + elif self.path == '/bridge/combat/flee': + # POST /bridge/combat/flee — flee from combat + # Body: { user_id, username, room } + user_id = body.get('user_id', 'anonymous') + username = body.get('username', 'Anonymous') + room = body.get('room', 'The Threshold') + result = combat_manager.flee(user_id) + if "error" in result: + self._json_response(result, 400) + else: + notification_manager.broadcast_room( + room, "combat_flee", + f"{username} flees from combat!", + exclude_user=user_id, data=result) + self._json_response(result) + else: self._json_response({"error": "not found"}, 404)