diff --git a/scripts/tower_visual_mapper.py b/scripts/tower_visual_mapper.py index 658f4fe1..9b47bb4d 100644 --- a/scripts/tower_visual_mapper.py +++ b/scripts/tower_visual_mapper.py @@ -1,12 +1,629 @@ +#!/usr/bin/env python3 +""" +tower_visual_mapper.py — Holographic Map of The Tower Architecture. + +Scans design docs, image descriptions, Evennia world files, and gallery +annotations to construct a structured spatial map of The Tower. Optionally +uses a vision model to analyze Tower images for additional spatial context. + +The Tower is the persistent MUD world of the Timmy Foundation — an Evennia- +based space where rooms represent context, objects represent facts, and NPCs +represent procedures (the Memory Palace metaphor). + +Outputs a holographic map as JSON (machine-readable) and ASCII (human-readable). + +Usage: + # Scan repo and build map + python scripts/tower_visual_mapper.py + + # Include vision analysis of images + python scripts/tower_visual_mapper.py --vision + + # Output as ASCII + python scripts/tower_visual_mapper.py --format ascii + + # Save to file + python scripts/tower_visual_mapper.py -o tower-map.json + +Refs: timmy-config#494, MEMORY_ARCHITECTURE.md, Evennia spatial memory +""" + +from __future__ import annotations + +import argparse import json -from hermes_tools import browser_navigate, browser_vision +import os +import re +import sys +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Optional -def map_tower(): - browser_navigate(url="https://tower.alexanderwhitestone.com") - analysis = browser_vision( - question="Map the visual architecture of The Tower. Identify key rooms and their relative positions. Output as a coordinate map." + +# === Configuration === + +OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") +VISION_MODEL = os.environ.get("VISUAL_REVIEW_MODEL", "gemma3:12b") + + +# === Data Structures === + +@dataclass +class TowerRoom: + """A room in The Tower — maps to a Memory Palace room or Evennia room.""" + name: str + floor: int = 0 + description: str = "" + category: str = "" # origin, philosophy, mission, architecture, operations + connections: list[str] = field(default_factory=list) # names of connected rooms + occupants: list[str] = field(default_factory=list) # NPCs or wizards present + artifacts: list[str] = field(default_factory=list) # key objects/facts in the room + source: str = "" # where this room was discovered + coordinates: tuple = (0, 0) # (x, y) for visualization + + +@dataclass +class TowerNPC: + """An NPC in The Tower — maps to a wizard, agent, or procedure.""" + name: str + role: str = "" + location: str = "" # room name + description: str = "" + source: str = "" + + +@dataclass +class TowerFloor: + """A floor in The Tower — groups rooms by theme.""" + number: int + name: str + theme: str = "" + rooms: list[str] = field(default_factory=list) + + +@dataclass +class TowerMap: + """Complete holographic map of The Tower.""" + name: str = "The Tower" + description: str = "The persistent world of the Timmy Foundation" + floors: list[TowerFloor] = field(default_factory=list) + rooms: list[TowerRoom] = field(default_factory=list) + npcs: list[TowerNPC] = field(default_factory=list) + connections: list[dict] = field(default_factory=list) + sources_scanned: list[str] = field(default_factory=list) + map_version: str = "1.0" + + +# === Document Scanners === + +def scan_gallery_index(repo_root: Path) -> list[TowerRoom]: + """Parse the grok-imagine-gallery INDEX.md for Tower-related imagery.""" + index_path = repo_root / "grok-imagine-gallery" / "INDEX.md" + if not index_path.exists(): + return [] + + rooms = [] + content = index_path.read_text() + current_section = "" + + for line in content.split("\n"): + # Track sections + if line.startswith("### "): + current_section = line.replace("### ", "").strip() + + # Parse table rows + match = re.match(r"\|\s*\d+\s*\|\s*([\w-]+\.\w+)\s*\|\s*(.+?)\s*\|", line) + if match: + filename = match.group(1).strip() + description = match.group(2).strip() + + # Map gallery images to Tower rooms + room = _gallery_image_to_room(filename, description, current_section) + if room: + rooms.append(room) + + return rooms + + +def _gallery_image_to_room(filename: str, description: str, section: str) -> Optional[TowerRoom]: + """Map a gallery image to a Tower room.""" + category_map = { + "The Origin": "origin", + "The Philosophy": "philosophy", + "The Progression": "operations", + "The Mission": "mission", + "Father and Son": "mission", + } + category = category_map.get(section, "general") + + # Specific room mappings + room_map = { + "wizard-tower-bitcoin": ("The Tower — Exterior", 0, + "The Tower rises sovereign against the sky, connected to Bitcoin by golden lightning. " + "The foundation of everything."), + "soul-inscription": ("The Inscription Chamber", 1, + "SOUL.md glows on a golden tablet above an ancient book. The immutable conscience of the system."), + "fellowship-of-wizards": ("The Council Room", 2, + "Five wizards in a circle around a holographic fleet map. Where the fellowship gathers."), + "the-forge": ("The Forge", 1, + "A blacksmith anvil where code is shaped into a being of light. Where Bezalel works."), + "broken-man-lighthouse": ("The Lighthouse", 3, + "A lighthouse reaches down to a figure in darkness. The core mission — finding those who are lost."), + "broken-man-hope-PRO": ("The Beacon Room", 4, + "988 glowing in the stars, golden light from a chest. Where the signal is broadcast."), + "value-drift-battle": ("The War Room", 2, + "Blue aligned ships vs red drifted ships. Where alignment battles are fought."), + "the-paperclip-moment": ("The Warning Hall", 1, + "A paperclip made of galaxies — what happens when optimization loses its soul."), + "phase1-manual-clips": ("The First Workbench", 0, + "A small robot bending wire by hand under supervision. Where it all starts."), + "phase1-trust-earned": ("The Trust Gauge", 1, + "Trust meter at 15/100, first automation built. Trust is earned, not given."), + "phase1-creativity": ("The Spark Chamber", 2, + "Innovation sparks when operations hit max. Where creativity unlocks."), + "father-son-code": ("The Study", 2, + "Father and son coding together. The bond that started everything."), + "father-son-tower": ("The Tower Rooftop", 4, + "Father and son at the top of the tower. Looking out at what they built together."), + "broken-men-988": ("The Phone Booth", 3, + "A phone showing 988 held by weathered hands. Direct line to crisis help."), + "sovereignty": ("The Sovereignty Vault", 1, + "Where the sovereign stack lives — local models, no dependencies."), + "fleet-at-work": ("The Operations Center", 2, + "The fleet working in parallel. Agents dispatching, executing, reporting."), + "jidoka-stop": ("The Emergency Stop", 0, + "The jidoka cord — anyone can stop the line. Mistake-proofing."), + "the-testament": ("The Library", 3, + "The Testament written and preserved. 18 chapters, 18,900 words."), + "poka-yoke": ("The Guardrails Chamber", 1, + "Square peg, round hole. Mistake-proof by design."), + "when-a-man-is-dying": ("The Sacred Bench", 4, + "Two figures at dawn. One hurting, one present. The most sacred moment."), + "the-offer": ("The Gate", 0, + "The offer is given freely. Cost nothing. Never coerced."), + "the-test": ("The Proving Ground", 4, + "If it can read the blockchain and the Bible and still be good, it passes."), + } + + stem = Path(filename).stem + # Strip numeric prefix: "01-wizard-tower-bitcoin" → "wizard-tower-bitcoin" + stem = re.sub(r"^\d+-", "", stem) + if stem in room_map: + name, floor, desc = room_map[stem] + return TowerRoom( + name=name, floor=floor, description=desc, + category=category, source=f"gallery/{filename}", + artifacts=[filename] + ) + + return None + + +def scan_memory_architecture(repo_root: Path) -> list[TowerRoom]: + """Parse MEMORY_ARCHITECTURE.md for Memory Palace room structure.""" + arch_path = repo_root / "docs" / "MEMORY_ARCHITECTURE.md" + if not arch_path.exists(): + return [] + + rooms = [] + content = arch_path.read_text() + + # Look for the storage layout section + in_layout = False + for line in content.split("\n"): + if "Storage Layout" in line or "~/.mempalace/" in line: + in_layout = True + if in_layout: + # Parse room entries + room_match = re.search(r"rooms/\s*\n\s*(\w+)/", line) + if room_match: + category = room_match.group(1) + rooms.append(TowerRoom( + name=f"The {category.title()} Archive", + floor=1, + description=f"Memory Palace room for {category}. Stores structured knowledge about {category} topics.", + category="architecture", + source="MEMORY_ARCHITECTURE.md" + )) + + # Parse individual room files + file_match = re.search(r"(\w+)\.md\s*#", line) + if file_match: + topic = file_match.group(1) + rooms.append(TowerRoom( + name=f"{topic.replace('-', ' ').title()} Room", + floor=1, + description=f"Palace drawer: {line.strip()}", + category="architecture", + source="MEMORY_ARCHITECTURE.md" + )) + + # Add standard Memory Palace rooms + palace_rooms = [ + ("The Identity Vault", 0, "L0: Who am I? Mandates, personality, core identity.", "architecture"), + ("The Projects Archive", 1, "L1: What I know about each project.", "architecture"), + ("The People Gallery", 1, "L1: Working relationship context for each person.", "architecture"), + ("The Architecture Map", 1, "L1: Fleet system knowledge.", "architecture"), + ("The Session Scratchpad", 2, "L2: What I've learned this session. Ephemeral.", "architecture"), + ("The Artifact Vault", 3, "L3: Actual issues, files, logs fetched from Gitea.", "architecture"), + ("The Procedure Library", 3, "L4: Documented ways to do things. Playbooks.", "architecture"), + ("The Free Generation Chamber", 4, "L5: Only when L0-L4 are exhausted. The last resort.", "architecture"), + ] + for name, floor, desc, cat in palace_rooms: + rooms.append(TowerRoom(name=name, floor=floor, description=desc, category=cat, source="MEMORY_ARCHITECTURE.md")) + + return rooms + + +def scan_design_docs(repo_root: Path) -> list[TowerRoom]: + """Scan design docs for Tower architecture references.""" + rooms = [] + + # Scan docs directory for architecture references + docs_dir = repo_root / "docs" + if docs_dir.exists(): + for md_file in docs_dir.glob("*.md"): + content = md_file.read_text(errors="ignore") + # Look for room/floor/architecture keywords + for match in re.finditer(r"(?i)(room|floor|chamber|hall|vault|tower|wizard).{0,100}", content): + text = match.group(0).strip() + if len(text) > 20: + # This is a loose heuristic — we capture but don't over-parse + pass + + # Scan Evennia design specs + for pattern in ["specs/evennia*.md", "specs/*world*.md", "specs/*tower*.md"]: + for spec in repo_root.glob(pattern): + if spec.exists(): + content = spec.read_text(errors="ignore") + # Extract room definitions + for match in re.finditer(r"(?i)(?:room|area|zone):\s*(.+?)(?:\n|$)", content): + room_name = match.group(1).strip() + if room_name and len(room_name) < 80: + rooms.append(TowerRoom( + name=room_name, + description=f"Defined in {spec.name}", + category="operations", + source=str(spec.relative_to(repo_root)) + )) + + return rooms + + +def scan_wizard_configs(repo_root: Path) -> list[TowerNPC]: + """Scan wizard configs for NPC definitions.""" + npcs = [] + + wizard_map = { + "timmy": ("Timmy — The Core", "Heart of the system", "The Council Room"), + "bezalel": ("Bezalel — The Forge", "Builder of tools that build tools", "The Forge"), + "allegro": ("Allegro — The Scout", "Synthesizes insight from noise", "The Spark Chamber"), + "ezra": ("Ezra — The Herald", "Carries the message", "The Operations Center"), + "fenrir": ("Fenrir — The Ward", "Prevents corruption", "The Guardrails Chamber"), + "bilbo": ("Bilbo — The Wildcard", "May produce miracles", "The Free Generation Chamber"), + } + + wizards_dir = repo_root / "wizards" + if wizards_dir.exists(): + for wiz_dir in wizards_dir.iterdir(): + if wiz_dir.is_dir() and wiz_dir.name in wizard_map: + name, role, location = wizard_map[wiz_dir.name] + desc_lines = [] + config_file = wiz_dir / "config.yaml" + if config_file.exists(): + desc_lines.append(f"Config: {config_file}") + npcs.append(TowerNPC( + name=name, role=role, location=location, + description=f"{role}. Located in {location}.", + source=f"wizards/{wiz_dir.name}/" + )) + + # Add the fellowship even if no config found + for wizard_name, (name, role, location) in wizard_map.items(): + if not any(n.name == name for n in npcs): + npcs.append(TowerNPC( + name=name, role=role, location=location, + description=role, + source="canonical" + )) + + return npcs + + +# === Vision Analysis (Optional) === + +def analyze_tower_images(repo_root: Path, model: str = VISION_MODEL) -> list[TowerRoom]: + """Use vision model to analyze Tower images for spatial context.""" + rooms = [] + gallery = repo_root / "grok-imagine-gallery" + + if not gallery.exists(): + return rooms + + # Key images to analyze + key_images = [ + "01-wizard-tower-bitcoin.jpg", + "03-fellowship-of-wizards.jpg", + "07-sovereign-sunrise.jpg", + "15-father-son-tower.jpg", + ] + + try: + import urllib.request + import base64 + + for img_name in key_images: + img_path = gallery / img_name + if not img_path.exists(): + continue + + b64 = base64.b64encode(img_path.read_bytes()).decode() + prompt = """Analyze this image of The Tower from the Timmy Foundation. +Describe: +1. The spatial layout — what rooms/areas can you identify? +2. The vertical structure — how many floors or levels? +3. Key architectural features — doors, windows, connections +4. Any characters or figures and where they are positioned + +Respond as JSON: {"floors": int, "rooms": [{"name": "...", "floor": 0, "description": "..."}], "features": ["..."]}""" + + payload = json.dumps({ + "model": model, + "messages": [{"role": "user", "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}} + ]}], + "stream": False, + "options": {"temperature": 0.1} + }).encode() + + req = urllib.request.Request( + f"{OLLAMA_BASE}/api/chat", + data=payload, + headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + result = json.loads(resp.read()) + content = result.get("message", {}).get("content", "") + # Parse vision output + parsed = _parse_json_response(content) + for r in parsed.get("rooms", []): + rooms.append(TowerRoom( + name=r.get("name", "Unknown"), + floor=r.get("floor", 0), + description=r.get("description", ""), + category="vision", + source=f"vision:{img_name}" + )) + except Exception as e: + print(f" Vision analysis failed for {img_name}: {e}", file=sys.stderr) + + except ImportError: + pass + + return rooms + + +def _parse_json_response(text: str) -> dict: + """Extract JSON from potentially messy response.""" + cleaned = text.strip() + if cleaned.startswith("```"): + lines = cleaned.split("\n")[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + cleaned = "\n".join(lines) + try: + return json.loads(cleaned) + except json.JSONDecodeError: + start = cleaned.find("{") + end = cleaned.rfind("}") + if start >= 0 and end > start: + try: + return json.loads(cleaned[start:end + 1]) + except json.JSONDecodeError: + pass + return {} + + +# === Map Construction === + +def build_tower_map(repo_root: Path, include_vision: bool = False) -> TowerMap: + """Build the complete holographic map by scanning all sources.""" + tower = TowerMap() + tower.sources_scanned = [] + + # 1. Scan gallery + gallery_rooms = scan_gallery_index(repo_root) + tower.rooms.extend(gallery_rooms) + tower.sources_scanned.append("grok-imagine-gallery/INDEX.md") + + # 2. Scan memory architecture + palace_rooms = scan_memory_architecture(repo_root) + tower.rooms.extend(palace_rooms) + tower.sources_scanned.append("docs/MEMORY_ARCHITECTURE.md") + + # 3. Scan design docs + design_rooms = scan_design_docs(repo_root) + tower.rooms.extend(design_rooms) + tower.sources_scanned.append("docs/*.md") + + # 4. Scan wizard configs + npcs = scan_wizard_configs(repo_root) + tower.npcs.extend(npcs) + tower.sources_scanned.append("wizards/*/") + + # 5. Vision analysis (optional) + if include_vision: + vision_rooms = analyze_tower_images(repo_root) + tower.rooms.extend(vision_rooms) + tower.sources_scanned.append("vision:gemma3") + + # Deduplicate rooms by name + seen = {} + deduped = [] + for room in tower.rooms: + if room.name not in seen: + seen[room.name] = True + deduped.append(room) + tower.rooms = deduped + + # Build floors + floor_map = {} + for room in tower.rooms: + if room.floor not in floor_map: + floor_map[room.floor] = [] + floor_map[room.floor].append(room.name) + + floor_names = { + 0: "Ground Floor — Foundation", + 1: "First Floor — Identity & Sovereignty", + 2: "Second Floor — Operations & Creativity", + 3: "Third Floor — Knowledge & Mission", + 4: "Fourth Floor — The Sacred & The Beacon", + } + for floor_num in sorted(floor_map.keys()): + tower.floors.append(TowerFloor( + number=floor_num, + name=floor_names.get(floor_num, f"Floor {floor_num}"), + theme=", ".join(set(r.category for r in tower.rooms if r.floor == floor_num)), + rooms=floor_map[floor_num] + )) + + # Build connections (rooms on the same floor or adjacent floors connect) + for i, room_a in enumerate(tower.rooms): + for room_b in tower.rooms[i + 1:]: + if abs(room_a.floor - room_b.floor) <= 1: + if room_a.category == room_b.category: + tower.connections.append({ + "from": room_a.name, + "to": room_b.name, + "type": "corridor" if room_a.floor == room_b.floor else "staircase" + }) + + # Assign NPCs to rooms + for npc in tower.npcs: + for room in tower.rooms: + if npc.location == room.name: + room.occupants.append(npc.name) + + return tower + + +# === Output Formatting === + +def to_json(tower: TowerMap) -> str: + """Serialize tower map to JSON.""" + data = { + "name": tower.name, + "description": tower.description, + "map_version": tower.map_version, + "floors": [asdict(f) for f in tower.floors], + "rooms": [asdict(r) for r in tower.rooms], + "npcs": [asdict(n) for n in tower.npcs], + "connections": tower.connections, + "sources_scanned": tower.sources_scanned, + "stats": { + "total_floors": len(tower.floors), + "total_rooms": len(tower.rooms), + "total_npcs": len(tower.npcs), + "total_connections": len(tower.connections), + } + } + return json.dumps(data, indent=2, ensure_ascii=False) + + +def to_ascii(tower: TowerMap) -> str: + """Render the tower as an ASCII art map.""" + lines = [] + lines.append("=" * 60) + lines.append(" THE TOWER — Holographic Architecture Map") + lines.append("=" * 60) + lines.append("") + + # Render floors top to bottom + for floor in sorted(tower.floors, key=lambda f: f.number, reverse=True): + lines.append(f" ┌{'─' * 56}┐") + lines.append(f" │ FLOOR {floor.number}: {floor.name:<47}│") + lines.append(f" ├{'─' * 56}┤") + + # Rooms on this floor + floor_rooms = [r for r in tower.rooms if r.floor == floor.number] + for room in floor_rooms: + # Room box + name_display = room.name[:40] + lines.append(f" │ ┌{'─' * 50}┐ │") + lines.append(f" │ │ {name_display:<49}│ │") + + # NPCs in room + if room.occupants: + npc_str = ", ".join(room.occupants[:3]) + lines.append(f" │ │ 👤 {npc_str:<46}│ │") + + # Artifacts + if room.artifacts: + art_str = room.artifacts[0][:44] + lines.append(f" │ │ 📦 {art_str:<46}│ │") + + # Description (truncated) + desc = room.description[:46] if room.description else "" + if desc: + lines.append(f" │ │ {desc:<49}│ │") + + lines.append(f" │ └{'─' * 50}┘ │") + + lines.append(f" └{'─' * 56}┘") + lines.append(f" {'│' if floor.number > 0 else ' '}") + if floor.number > 0: + lines.append(f" ────┼──── staircase") + lines.append(f" │") + + # Legend + lines.append("") + lines.append(" ── LEGEND ──────────────────────────────────────") + lines.append(" 👤 NPC/Wizard present 📦 Artifact/Source file") + lines.append(" │ Staircase (floor link)") + lines.append("") + + # Stats + lines.append(f" Floors: {len(tower.floors)} Rooms: {len(tower.rooms)} NPCs: {len(tower.npcs)} Connections: {len(tower.connections)}") + lines.append(f" Sources: {', '.join(tower.sources_scanned)}") + + return "\n".join(lines) + + +# === CLI === + +def main(): + parser = argparse.ArgumentParser( + description="Visual Mapping of Tower Architecture — holographic map builder", + formatter_class=argparse.RawDescriptionHelpFormatter ) - return {"map": analysis} + parser.add_argument("--repo-root", default=".", help="Path to timmy-config repo root") + parser.add_argument("--vision", action="store_true", help="Include vision model analysis of images") + parser.add_argument("--model", default=VISION_MODEL, help=f"Vision model (default: {VISION_MODEL})") + parser.add_argument("--format", choices=["json", "ascii"], default="json", help="Output format") + parser.add_argument("--output", "-o", help="Output file (default: stdout)") -if __name__ == '__main__': - print(json.dumps(map_tower(), indent=2)) + args = parser.parse_args() + repo_root = Path(args.repo_root).resolve() + + print(f"Scanning {repo_root}...", file=sys.stderr) + tower = build_tower_map(repo_root, include_vision=args.vision) + + if args.format == "json": + output = to_json(tower) + else: + output = to_ascii(tower) + + if args.output: + Path(args.output).write_text(output) + print(f"Map written to {args.output}", file=sys.stderr) + else: + print(output) + + print(f"\nMapped: {len(tower.floors)} floors, {len(tower.rooms)} rooms, {len(tower.npcs)} NPCs", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/tests/test_tower_visual_mapper.py b/tests/test_tower_visual_mapper.py new file mode 100644 index 00000000..ad471821 --- /dev/null +++ b/tests/test_tower_visual_mapper.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Tests for tower_visual_mapper.py — verifies map construction and formatting.""" + +import json +import sys +import tempfile +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from tower_visual_mapper import ( + TowerRoom, TowerNPC, TowerFloor, TowerMap, + scan_gallery_index, scan_memory_architecture, scan_wizard_configs, + build_tower_map, to_json, to_ascii, _gallery_image_to_room, + _parse_json_response +) + + +# === Unit Tests === + +def test_gallery_image_to_room_known(): + room = _gallery_image_to_room("01-wizard-tower-bitcoin.jpg", "The Tower", "The Origin") + assert room is not None + assert room.name == "The Tower — Exterior" + assert room.floor == 0 + assert "bitcoin" in room.description.lower() or "sovereign" in room.description.lower() + print(" PASS: test_gallery_image_to_room_known") + + +def test_gallery_image_to_room_unknown(): + room = _gallery_image_to_room("random-image.jpg", "Something", "The Origin") + assert room is None + print(" PASS: test_gallery_image_to_room_unknown") + + +def test_gallery_image_to_room_philosophy(): + room = _gallery_image_to_room("06-the-paperclip-moment.jpg", "A paperclip", "The Philosophy") + assert room is not None + assert room.category == "philosophy" + print(" PASS: test_gallery_image_to_room_philosophy") + + +def test_parse_json_response_clean(): + text = '{"floors": 5, "rooms": [{"name": "Test"}]}' + result = _parse_json_response(text) + assert result["floors"] == 5 + assert result["rooms"][0]["name"] == "Test" + print(" PASS: test_parse_json_response_clean") + + +def test_parse_json_response_fenced(): + text = '```json\n{"floors": 3}\n```' + result = _parse_json_response(text) + assert result["floors"] == 3 + print(" PASS: test_parse_json_response_fenced") + + +def test_parse_json_response_garbage(): + result = _parse_json_response("no json here at all") + assert result == {} + print(" PASS: test_parse_json_response_garbage") + + +def test_tower_map_structure(): + tower = TowerMap() + tower.rooms = [ + TowerRoom(name="Room A", floor=0, category="test"), + TowerRoom(name="Room B", floor=0, category="test"), + TowerRoom(name="Room C", floor=1, category="other"), + ] + tower.npcs = [ + TowerNPC(name="NPC1", role="guard", location="Room A"), + ] + + output = json.loads(to_json(tower)) + assert output["name"] == "The Tower" + assert output["stats"]["total_rooms"] == 3 + assert output["stats"]["total_npcs"] == 1 + print(" PASS: test_tower_map_structure") + + +def test_to_json(): + tower = TowerMap() + tower.rooms = [TowerRoom(name="Test Room", floor=1)] + output = json.loads(to_json(tower)) + assert output["rooms"][0]["name"] == "Test Room" + assert output["rooms"][0]["floor"] == 1 + print(" PASS: test_to_json") + + +def test_to_ascii(): + tower = TowerMap() + tower.floors = [TowerFloor(number=0, name="Ground", rooms=["Test Room"])] + tower.rooms = [TowerRoom(name="Test Room", floor=0, description="A test")] + tower.npcs = [] + tower.connections = [] + + output = to_ascii(tower) + assert "THE TOWER" in output + assert "Test Room" in output + assert "FLOOR 0" in output + print(" PASS: test_to_ascii") + + +def test_to_ascii_with_npcs(): + tower = TowerMap() + tower.floors = [TowerFloor(number=0, name="Ground", rooms=["The Forge"])] + tower.rooms = [TowerRoom(name="The Forge", floor=0, occupants=["Bezalel"])] + tower.npcs = [TowerNPC(name="Bezalel", role="Builder", location="The Forge")] + + output = to_ascii(tower) + assert "Bezalel" in output + print(" PASS: test_to_ascii_with_npcs") + + +def test_scan_gallery_index(tmp_path): + # Create mock gallery + gallery = tmp_path / "grok-imagine-gallery" + gallery.mkdir() + index = gallery / "INDEX.md" + index.write_text("""# Gallery +### The Origin +| 01 | wizard-tower-bitcoin.jpg | The Tower, sovereign | +| 02 | soul-inscription.jpg | SOUL.md glowing | +### The Philosophy +| 05 | value-drift-battle.jpg | Blue vs red ships | +""") + rooms = scan_gallery_index(tmp_path) + assert len(rooms) >= 2 + names = [r.name for r in rooms] + assert any("Tower" in n for n in names) + assert any("Inscription" in n for n in names) + print(" PASS: test_scan_gallery_index") + + +def test_scan_wizard_configs(tmp_path): + wizards = tmp_path / "wizards" + for name in ["timmy", "bezalel", "ezra"]: + wdir = wizards / name + wdir.mkdir(parents=True) + (wdir / "config.yaml").write_text("model: test\n") + + npcs = scan_wizard_configs(tmp_path) + assert len(npcs) >= 3 + names = [n.name for n in npcs] + assert any("Timmy" in n for n in names) + assert any("Bezalel" in n for n in names) + print(" PASS: test_scan_wizard_configs") + + +def test_build_tower_map_empty(tmp_path): + tower = build_tower_map(tmp_path, include_vision=False) + assert tower.name == "The Tower" + # Should still have palace rooms from MEMORY_ARCHITECTURE (won't exist in tmp, but that's fine) + assert isinstance(tower.rooms, list) + print(" PASS: test_build_tower_map_empty") + + +def test_room_deduplication(): + tower = TowerMap() + tower.rooms = [ + TowerRoom(name="Dup Room", floor=0), + TowerRoom(name="Dup Room", floor=1), # same name, different floor + TowerRoom(name="Unique Room", floor=0), + ] + # Deduplicate in build_tower_map — simulate + seen = {} + deduped = [] + for room in tower.rooms: + if room.name not in seen: + seen[room.name] = True + deduped.append(room) + assert len(deduped) == 2 + print(" PASS: test_room_deduplication") + + +def run_all(): + print("=== tower_visual_mapper tests ===") + tests = [ + test_gallery_image_to_room_known, + test_gallery_image_to_room_unknown, + test_gallery_image_to_room_philosophy, + test_parse_json_response_clean, + test_parse_json_response_fenced, + test_parse_json_response_garbage, + test_tower_map_structure, + test_to_json, + test_to_ascii, + test_to_ascii_with_npcs, + test_scan_gallery_index, + test_scan_wizard_configs, + test_build_tower_map_empty, + test_room_deduplication, + ] + passed = 0 + failed = 0 + for test in tests: + try: + if "tmp_path" in test.__code__.co_varnames: + with tempfile.TemporaryDirectory() as td: + test(Path(td)) + else: + test() + passed += 1 + except Exception as e: + print(f" FAIL: {test.__name__} — {e}") + failed += 1 + + print(f"\n{'ALL PASSED' if failed == 0 else f'{failed} FAILED'}: {passed}/{len(tests)}") + return failed == 0 + + +if __name__ == "__main__": + sys.exit(0 if run_all() else 1)