"""dungeon.py — Multi-room dungeon manager for SnapQuest. Each uploaded photo becomes one room. The final room always spawns a BOSS whose name and abilities are derived from the most prominent object in that photo. Room 1 is easy, Room 2 is medium, Room 3 (boss) is hard. No external dependencies beyond what vision.py already uses. """ from __future__ import annotations import random from typing import Any # ── Difficulty scaling ────────────────────────────────────────────────────── ROOM_CONFIG = [ {"label": "Entry Hall", "difficulty": "easy", "enemy_hp": 30, "xp": 20, "loot_count": 1}, {"label": "Inner Chamber", "difficulty": "medium", "enemy_hp": 60, "xp": 40, "loot_count": 2}, {"label": "Boss Lair", "difficulty": "hard", "enemy_hp": 120, "xp": 100, "loot_count": 3}, ] LOOT_POOL = [ "Ancient Coin", "Healing Potion", "Shadow Cloak", "Enchanted Rope", "Glowing Shard", "Iron Key", "Arcane Scroll", "Bone Dagger", "Ember Flask", "Thornwood Staff", "Silver Ring", "Rune Stone", ] BOSS_TITLES = ["Guardian", "Sentinel", "Warden", "Devourer", "Specter", "Revenant"] BOSS_ATTACKS = [ "delivers a crushing blow", "unleashes a wave of dark energy", "sweeps the room with shadow tendrils", "lets out a terrifying roar that shakes the walls", "channels the room's power into a devastating strike", ] # ── Boss generation ───────────────────────────────────────────────────────── def generate_boss(objects_found: list[str]) -> dict[str, Any]: """Turn the most prominent object in the final room into a boss.""" anchor = objects_found[0] if objects_found else "Shadow" title = random.choice(BOSS_TITLES) name = f"The {anchor.title()} {title}" hp = ROOM_CONFIG[2]["enemy_hp"] return { "name": name, "hp": hp, "max_hp": hp, "attacks": BOSS_ATTACKS, "alive": True, "intro": ( f"The air grows cold. From the depths of the room, " f"{name} rises — the very essence of this place given terrible form. " f"Its {random.choice(objects_found[1:] if len(objects_found) > 1 else ['shadow'])} " f"pulses with malevolent energy." ), } # ── Room builder ──────────────────────────────────────────────────────────── def build_rooms(photo_scenes: list[dict[str, Any]]) -> list[dict[str, Any]]: """Convert a list of vision.py scene dicts into dungeon rooms. photo_scenes: 1–3 dicts returned by vision.analyze_scene() Returns a list of room dicts ready to store in game_state["rooms"]. """ rooms: list[dict[str, Any]] = [] n = len(photo_scenes) for i, scene in enumerate(photo_scenes): cfg_idx = min(i, 2) # clamp to 3 configs if n == 1: cfg_idx = 2 # single photo → straight to boss elif n == 2: cfg_idx = 0 if i == 0 else 2 # two photos → entry + boss cfg = ROOM_CONFIG[cfg_idx] is_boss = (i == n - 1) # last room is always boss room: dict[str, Any] = { "index": i, "label": cfg["label"], "difficulty": cfg["difficulty"], "scene_name": scene.get("scene_name", f"Room {i+1}"), "scene_description": scene.get("scene_description", ""), "atmosphere": scene.get("atmosphere", ""), "objects_found": scene.get("objects_found", []), "choices": scene.get("choices", []), "enemy_hp": cfg["enemy_hp"], "enemy_max_hp": cfg["enemy_hp"], "enemy_alive": True, "xp_reward": cfg["xp"], "loot": random.sample(LOOT_POOL, min(cfg["loot_count"], len(LOOT_POOL))), "cleared": False, "is_boss": is_boss, "boss": generate_boss(scene.get("objects_found", ["Shadow"])) if is_boss else None, "visited": False, } rooms.append(room) return rooms # ── State helpers ─────────────────────────────────────────────────────────── def current_room(state: dict[str, Any]) -> dict[str, Any]: rooms = state.get("rooms", []) idx = state.get("room_index", 0) return rooms[min(idx, len(rooms) - 1)] def can_advance(state: dict[str, Any]) -> bool: """Player can advance if current room is cleared and there's a next room.""" room = current_room(state) rooms = state.get("rooms", []) idx = state.get("room_index", 0) return room.get("cleared", False) and (idx + 1) < len(rooms) def advance_room(state: dict[str, Any]) -> dict[str, Any]: """Move to next room. Returns updated state (mutates in place).""" if can_advance(state): state["room_index"] += 1 room = current_room(state) room["visited"] = True return state def apply_combat(state: dict[str, Any], action: str) -> tuple[dict[str, Any], str]: """ Very lightweight combat resolution — called from engine_photo after DM narrates. Returns (updated_state, combat_message). Combat only triggers on attack-flavoured actions. """ ATTACK_KEYWORDS = {"attack", "fight", "strike", "hit", "stab", "slash", "shoot", "cast", "blast", "smash", "punch", "kick", "throw", "use"} action_lower = action.lower() is_attack = any(kw in action_lower for kw in ATTACK_KEYWORDS) room = current_room(state) msg = "" if not is_attack or not room.get("enemy_alive", False): return state, msg # Player deals 10–25 damage player_dmg = random.randint(10, 25) # Boss or enemy deals 5–15 damage back enemy_dmg = random.randint(5, 15) if room.get("is_boss") and room.get("boss"): boss = room["boss"] boss["hp"] = max(0, boss["hp"] - player_dmg) attack_desc = random.choice(boss["attacks"]) msg = ( f"\n⚔ You strike {boss['name']} for {player_dmg} damage! " f"[{boss['hp']}/{boss['max_hp']} HP]\n" f"💀 {boss['name']} {attack_desc} — you take {enemy_dmg} damage!" ) if boss["hp"] <= 0: boss["alive"] = False room["enemy_alive"] = False room["cleared"] = True # Give loot loot = room.get("loot", []) state["inventory"] = state.get("inventory", []) + loot state["xp"] = state.get("xp", 0) + room.get("xp_reward", 100) msg += ( f"\n\n🏆 {boss['name']} DEFEATED!\n" f"✨ You gain {room['xp_reward']} XP!\n" f"💰 Loot collected: {', '.join(loot)}" ) else: room["enemy_hp"] = max(0, room.get("enemy_hp", 30) - player_dmg) msg = ( f"\n⚔ You strike for {player_dmg} damage! " f"[{room['enemy_hp']}/{room['enemy_max_hp']} HP]\n" f"💀 The creature retaliates — you take {enemy_dmg} damage!" ) if room["enemy_hp"] <= 0: room["enemy_alive"] = False room["cleared"] = True loot = room.get("loot", []) state["inventory"] = state.get("inventory", []) + loot state["xp"] = state.get("xp", 0) + room.get("xp_reward", 20) msg += ( f"\n\n✅ Room cleared!\n" f"✨ +{room['xp_reward']} XP | 💰 Loot: {', '.join(loot)}" ) # Apply damage to player state["hp"] = max(0, state.get("hp", 100) - enemy_dmg) return state, msg def minimap_html(state: dict[str, Any]) -> str: """Return a tiny pixel minimap as HTML showing room progression.""" rooms = state.get("rooms", []) idx = state.get("room_index", 0) if not rooms: return "" boxes = [] for i, room in enumerate(rooms): if i == idx: color, border = "#ffd36a", "2px solid #ffd36a" label = "▶" elif room.get("cleared"): color, border = "#8cff9b", "1px solid #8cff9b" label = "✓" elif room.get("is_boss"): color, border = "#ff6b6b", "1px dashed #ff6b6b" label = "☠" else: color, border = "#31513a", "1px solid #31513a" label = str(i + 1) tip = room.get("scene_name", f"Room {i+1}") boxes.append( f'