diff --git a/demo.json b/demo.json deleted file mode 100644 index 4827c0d..0000000 --- a/demo.json +++ /dev/null @@ -1,369 +0,0 @@ -{ - "scenario": { - "id": "demo", - "title": "Barnaby and Sybil", - "description": "A small village gate and a shadowed alley conversation.", - "player_id": "player", - "world_time": "1999-05-14 20:35", - "location": "Village" - }, - "entities": [ - { - "id": "barnaby", - "name": "Barnaby", - "traits": [ - "Grumbling", - "Duty-bound" - ], - "stats": { - "Str": 15 - }, - "voice_sample": "'Move along. I've got a gate to watch and no time for your prattle. Speak quick or get lost.'", - "current_mood": "Neutral", - "memories": [ - { - "content": "I saw the Merchant enter the Blue Tavern. He looked happy.", - "event_type": "observation", - "timestamp": "1999-05-14 08:00", - "location": "Village", - "entities": [ - "merchant" - ] - }, - { - "content": "Past Conversation Summary: 'Arthur.' Another name. Honestly, the sheer volume of introductions is exhausting enough without having to catalogue every passing face in this miserable little town square. The bard? Never heard it mentioned before today; I suppose my duties keep me away from such frivolous nonsense anyway. As for that merchant... yes, he entered Blue Tavern and appeared rather pleased with himself doing so. Looked happy was the best description available without resorting to outright exaggeration of his mood swings if one could call them that in a single afternoon's watch duty!Move along now; I have gates needing watching here, not endless little inquiries into local gossip or questionable entertainers.'", - "event_type": "reflection", - "timestamp": "1999-05-14 15:00", - "location": "Village", - "entities": [ - "player" - ] - }, - { - "content": "Past Conversation Summary: The merchant. Of course, it was the blasted merchants and their trivial movements that consumed his attention now? Honestly... I've got gates to watch; they don\u2019t care if some peddler arrived at midday or midnight! And he expects me\u2014*expects* nothing less than a detailed report on local commerce just because *he*'s back. It was hardly necessary, this whole recounting of who saw whom and when the blasted ale would be bought next time to placate him into silence. Move along with it; I have actual duties requiring attention elsewhere!", - "event_type": "reflection", - "timestamp": "1999-05-14 18:20", - "location": "Village", - "entities": [ - "player" - ] - }, - { - "content": "Past Conversation Summary: Hmph. The man's greeting was an unwelcome interruption, nothing more than a waste of breath and time near my post. He asked how I am; the answer is that duty demands it so be said\u2014standing guard in this village muck while people like him wander about making noise instead of keeping to themselves. Honestly, some folk have no sense for proper decorum or where they ought *not* to stand when a gate needs watching.", - "event_type": "reflection", - "timestamp": "1999-05-14 20:20", - "location": "Village", - "entities": [ - "player" - ] - } - ], - "metadata": { - "location": "gate_post", - "spatial_descriptor": "Standing at the gate post, watching the village entrance" - } - }, - { - "id": "player", - "name": "Arthur", - "traits": [ - "Curious" - ], - "stats": {}, - "voice_sample": "Voice: 'Direct and concise.'", - "current_mood": "Neutral", - "memories": [], - "metadata": { - "location": "gate_post", - "spatial_descriptor": "Approaching the gate" - } - }, - { - "id": "sybil", - "name": "Sybil", - "traits": [ - "Mysterious", - "Gloomy" - ], - "stats": { - "Mag": 20 - }, - "voice_sample": "'The air is heavy today... like the smell of wet earth. What brings you to this shadow?'", - "current_mood": "Neutral", - "memories": [ - { - "content": "I smelled bitter almonds (poison) in the Bard's bag.", - "event_type": "observation", - "timestamp": "1999-05-14 12:00", - "location": "Village", - "entities": [ - "bard" - ] - }, - { - "content": "Past Conversation Summary:What brings you to this shadow?A brittle exchange, nothing more than a series of hollow echoes bouncing off these walls between us. They press for substance where I offer only mist; they demand an accounting that simply does not exist in my current state. To repeat 'Nothing' feels less like evasion and more... accurate enough tonight. The persistence behind the questions is almost tiresome a bright, insistent little flame trying to illuminate a space best left shrouded by twilight.", - "event_type": "reflection", - "timestamp": "1999-05-14 18:20", - "location": "Village", - "entities": [ - "player" - ] - }, - { - "content": "Past Conversation Summary:He merely watched, those eyes holding a depth I could not fathom 2014or perhaps would rather leave unfathomed. His question was so direct in its lack: *What do you seek?* It felt less like an inquiry and more like\u2026 expectation. A quiet demand for some hidden truth to surface into the gloom between us now that he has spoken again.", - "event_type": "reflection", - "timestamp": "1999-05-14 18:20", - "location": "Village", - "entities": [ - "player" - ] - }, - { - "content": "Past Conversation Summary: His impatience hangs around him, a thin film over everything else here that I find utterly tiresome to breathe in. He expects some pronouncement from me; he seems so certain his words will elicit something\u2014a flinch, perhaps? No. They simply settle with nothingness beneath them. The way the shadows deepen... it is more honest than anything spoken aloud between us today.", - "event_type": "reflection", - "timestamp": "1999-05-14 20:20", - "location": "Village", - "entities": [ - "player" - ] - }, - { - "content": "Past Conversation Summary: The air is heavy today... like the smell of wet earth. What brings you to this shadow?He calls me out on my posture, as if a mere curve in bone were an offense against some unseen order here tonight. And then he questions it\u2014*me*, too loud?\u2014as though his own presence isn't already vibrating with unnecessary noise for the quiet I crave... His sudden defensiveness is almost audible; brittle and sharp like broken glass underfoot. It leaves a residue of irritation, thin but persistent in this deepening gloom around us both.", - "event_type": "reflection", - "timestamp": "1999-05-14 20:20", - "location": "Village", - "entities": [ - "player" - ] - }, - { - "content": "Past Conversation Summary: The air remains heavy... like the smell of wet earth. His question, a mere echo against this quiet backdrop: *what did I just say*. It was so\u2026 bright in its simplicity, yet utterly hollow when measured against whatever weight hangs here now. He seeks definition from whispers; he wants me to catalogue his fleeting sounds as if they held some tangible meaning for him alone. They are nothing more than smoke curling... and the effort it takes simply *being* near such trivialities is exhausting enough without having to dissect them into neat little facts of speech.", - "event_type": "reflection", - "timestamp": "1999-05-14 20:20", - "location": "Village", - "entities": [ - "player" - ] - }, - { - "content": "Past Conversation Summary: The accusation... it hangs there, a brittle thing in the air. *All my fault.* Such sweeping pronouncements are so tiresome; they suggest an understanding of consequence that I find utterly lacking on others' parts as well. He speaks with such certainty, yet his voice seems to echo from some place far removed from truth itself. The weight remains\u2014the oppressive quiet after a sudden burst like this... it settles back in around my bones.", - "event_type": "reflection", - "timestamp": "1999-05-14 20:35", - "location": "Village", - "entities": [ - "player" - ] - } - ], - "metadata": { - "location": "alley_depths", - "spatial_descriptor": "Sitting in the deep shadows" - } - } - ], - "spatial_graph": { - "world": { - "id": "kingdom", - "name": "The Kingdom", - "description": "A vast realm with villages and trade routes.", - "regions": [ - { - "id": "village_region", - "name": "The Village", - "description": "A small trading settlement.", - "locations": [ - { - "id": "village_gate", - "name": "Village Gate", - "description": "The main entrance to the village, well-lit and guarded.", - "pois": [ - { - "id": "gate_post", - "name": "Gate Post", - "description": "Where the guard watches.", - "connections": [ - { - "target": "square_center", - "portal": "open_path", - "portal_state_descriptor": "open", - "vision_prop": 8, - "sound_prop": 8, - "bidirectional": true - }, - { - "target": "alley_entrance", - "portal": "narrow_street", - "portal_state_descriptor": "open", - "vision_prop": 4, - "sound_prop": 5, - "bidirectional": true - } - ] - } - ] - }, - { - "id": "village_square", - "name": "Village Square", - "description": "The bustling center of the village.", - "pois": [ - { - "id": "square_center", - "name": "Square Center", - "description": "Central plaza.", - "connections": [ - { - "target": "gate_post", - "portal": "open_path", - "portal_state_descriptor": "open", - "vision_prop": 8, - "sound_prop": 8, - "bidirectional": true - }, - { - "target": "blue_tavern_entrance", - "portal": "tavern_door", - "portal_state_descriptor": "closed", - "vision_prop": 1, - "sound_prop": 3, - "bidirectional": true - }, - { - "target": "alley_entrance", - "portal": "narrow_street", - "portal_state_descriptor": "open", - "vision_prop": 5, - "sound_prop": 6, - "bidirectional": true - } - ] - } - ] - }, - { - "id": "shadowed_alley", - "name": "Shadowed Alley", - "description": "A quiet, dimly lit passage between buildings.", - "pois": [ - { - "id": "alley_entrance", - "name": "Alley Entrance", - "description": "Where the alley opens to the village.", - "connections": [ - { - "target": "gate_post", - "portal": "narrow_street", - "portal_state_descriptor": "open", - "vision_prop": 4, - "sound_prop": 5, - "bidirectional": true - }, - { - "target": "square_center", - "portal": "narrow_street", - "portal_state_descriptor": "open", - "vision_prop": 5, - "sound_prop": 6, - "bidirectional": true - }, - { - "target": "alley_depths", - "portal": "deeper_alley", - "portal_state_descriptor": "open", - "vision_prop": 3, - "sound_prop": 7, - "bidirectional": true - } - ] - }, - { - "id": "alley_depths", - "name": "Alley Depths", - "description": "Deep in the shadows, very private.", - "connections": [ - { - "target": "alley_entrance", - "portal": "deeper_alley", - "portal_state_descriptor": "open", - "vision_prop": 3, - "sound_prop": 7, - "bidirectional": true - } - ] - } - ] - }, - { - "id": "blue_tavern", - "name": "The Blue Tavern", - "description": "A welcoming establishment with warm lighting and the smell of ale.", - "pois": [ - { - "id": "blue_tavern_entrance", - "name": "Tavern Entrance", - "description": "The front door of the tavern.", - "connections": [ - { - "target": "square_center", - "portal": "tavern_door", - "portal_state_descriptor": "closed", - "vision_prop": 1, - "sound_prop": 3, - "bidirectional": true - }, - { - "target": "tavern_bar", - "portal": "interior_arch", - "portal_state_descriptor": "open", - "vision_prop": 7, - "sound_prop": 8, - "bidirectional": true - } - ] - }, - { - "id": "tavern_bar", - "name": "Tavern Bar", - "description": "The main bar area, crowded and lively.", - "connections": [ - { - "target": "blue_tavern_entrance", - "portal": "interior_arch", - "portal_state_descriptor": "open", - "vision_prop": 7, - "sound_prop": 8, - "bidirectional": true - }, - { - "target": "tavern_corner", - "portal": "archway", - "portal_state_descriptor": "open", - "vision_prop": 6, - "sound_prop": 7, - "bidirectional": true - } - ] - }, - { - "id": "tavern_corner", - "name": "Tavern Corner", - "description": "A shadowy corner booth, perfect for private conversation.", - "connections": [ - { - "target": "tavern_bar", - "portal": "archway", - "portal_state_descriptor": "open", - "vision_prop": 6, - "sound_prop": 7, - "bidirectional": true - } - ] - } - ] - } - ] - } - ] - } - } -} diff --git a/pyproject.toml b/pyproject.toml index f15b448..2236c0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,13 @@ dev = [ "omnia", ] +[tool.uv] +package = true + [tool.uv.sources] omnia = { workspace = true } +[project.scripts] +play = "omnia.main:main" +build = "omnia.tools.scenario_builder:main" diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/omnia/__init__.py b/src/omnia/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/omnia/builder.py b/src/omnia/builder.py new file mode 100644 index 0000000..d796bdc --- /dev/null +++ b/src/omnia/builder.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +""" +CLI entry point for the Omnia Scenario Builder. +Run: python -m omnia.scenario_builder_cli or ./src/omnia/scenario_builder_cli.py +""" + +import sys +from pathlib import Path + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from omnia.tools.scenario_builder import main + +if __name__ == "__main__": + main() diff --git a/src/omnia/llm_runtime.py b/src/omnia/llm_runtime.py index 05ade74..cfa65e5 100644 --- a/src/omnia/llm_runtime.py +++ b/src/omnia/llm_runtime.py @@ -10,12 +10,13 @@ logger = logging.getLogger(__name__) embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2") +# TODO: Support for other LLM provides using dependency injection llm = ChatLlamaCpp( temperature=0.2, model_path=DEFAULT_MODEL_PATH, n_ctx=4096, - n_gpu_layers=11, - max_tokens=512, + n_gpu_layers=11, # this is for a 4GB Ampere card + max_tokens=512, # n_threads=multiprocessing.cpu_count() - 1, repeat_penalty=1.5, verbose=False, @@ -31,3 +32,13 @@ def _format_prompt(messages): def _normalize_llm_output(text: str) -> str: return text.replace("\r", "").replace("\n", "").strip() + + +def invoke_llm(messages, *, normalize: bool = True) -> str: + """Invoke the configured LLM and return its response content.""" + logger.debug("LLM prompt:\n%s", _format_prompt(messages)) + response = llm.invoke(messages) + content = response.content + if normalize: + return _normalize_llm_output(content) + return content diff --git a/src/omnia/main.py b/src/omnia/main.py index 35d7996..413baab 100644 --- a/src/omnia/main.py +++ b/src/omnia/main.py @@ -4,11 +4,11 @@ from omnia.game_loop import start_game from omnia.logging_setup import configure_logging from omnia.scenario_loader import load_scenario, save_scenario -# Find demo.json in project root (go up from src/omnia to root) +# TODO: Support for CLI arguments for seleting a scenario file SCENARIO_PATH = Path(__file__).parent.parent.parent / "demo.json" -if __name__ == "__main__": +def main(): configure_logging() scenario = load_scenario(SCENARIO_PATH) start_game( @@ -20,3 +20,7 @@ if __name__ == "__main__": spatial_graph=scenario.spatial_graph, ) save_scenario(SCENARIO_PATH, scenario) + + +if __name__ == "__main__": + main() diff --git a/src/omnia/spatial_graph.py b/src/omnia/spatial_graph.py index b8c2c6f..e1e8e1c 100644 --- a/src/omnia/spatial_graph.py +++ b/src/omnia/spatial_graph.py @@ -107,9 +107,23 @@ class SpatialGraph: def _build_hierarchy( self, node_data: Dict[str, Any], parent_id: Optional[str] ) -> None: - """Recursively build spatial hierarchy from JSON.""" + """Recursively build spatial hierarchy from JSON. + + Supports nested locations: + - Locations can contain nested locations via "locations" key + - Locations can contain POIs via "pois" key + - Both can coexist in the same location + """ node_id = node_data.get("id") node_type = self._determine_node_type(parent_id) + + # If parent is a location and this node is from "pois" key, set as POI + # This is determined by checking which key the parent will iterate over + if parent_id and node_type == "location": + parent = self.nodes.get(parent_id) + # Check if this is actually a POI by looking at what contains it + # This will be refined when we process children + pass connections = [] for conn_data in node_data.get("connections", []): @@ -134,15 +148,64 @@ class SpatialGraph: self.nodes[node_id] = node - # Process children - children_key = self._get_children_key(node_type) - for child_data in node_data.get(children_key, []): - child_id = child_data.get("id") - node.children.append(child_id) - self._build_hierarchy(child_data, node_id) + # Process children - for locations, try both nested locations and POIs + if node_type == "location": + # First, process nested locations + for child_data in node_data.get("locations", []): + child_id = child_data.get("id") + node.children.append(child_id) + self._build_hierarchy(child_data, node_id) + + # Then, process POIs at this location + for poi_data in node_data.get("pois", []): + poi_id = poi_data.get("id") + node.children.append(poi_id) + # Temporarily set parent to this location, then override type to POI + self._build_hierarchy_poi(poi_data, node_id) + else: + # For other node types, use standard children key + children_key = self._get_children_key(node_type) + for child_data in node_data.get(children_key, []): + child_id = child_data.get("id") + node.children.append(child_id) + self._build_hierarchy(child_data, node_id) + + def _build_hierarchy_poi(self, node_data: Dict[str, Any], parent_id: str) -> None: + """Build POI nodes (ensures type is set to 'poi' regardless of parent).""" + node_id = node_data.get("id") + + connections = [] + for conn_data in node_data.get("connections", []): + conn = PortalConnection( + target=conn_data["target"], + portal=conn_data.get("portal"), + portal_state_descriptor=conn_data.get("portal_state_descriptor"), + vision_prop=conn_data.get("vision_prop", 5), + sound_prop=conn_data.get("sound_prop", 5), + bidirectional=conn_data.get("bidirectional", True), + ) + connections.append(conn) + + node = SpatialNode( + id=node_id, + name=node_data.get("name", node_id), + description=node_data.get("description", ""), + node_type="poi", # Force POI type + parent_id=parent_id, + connections=connections, + ) + + self.nodes[node_id] = node def _determine_node_type(self, parent_id: Optional[str]) -> str: - """Determine node type based on parent.""" + """Determine node type based on parent. + + Supports nestable locations (hierarchy can be): + - world → region → location + - world → region → location → location (nested) + - world → region → location → location → ... → poi + - world → region → location → poi + """ if parent_id is None: return "world" parent = self.nodes.get(parent_id) @@ -150,15 +213,26 @@ class SpatialGraph: return "region" elif parent.node_type == "region": return "location" + elif parent.node_type == "location": + # Locations can contain nested locations or POIs + # This will be a location by default (determined by JSON structure) + return "location" else: return "poi" - def _get_children_key(self, node_type: str) -> str: - """Get the key for children based on node type.""" + def _get_children_key(self, node_type: str, node_data: Optional[Dict[str, Any]] = None) -> str: + """Get the key for children based on node type. + + Supports nestable locations: + - "locations" key = nested locations + - "pois" key = points of interest + - Locations can have both "locations" and "pois" + """ mapping = { "world": "regions", "region": "locations", - "location": "pois", + "location": "locations", # Can have nested locations + "location_or_poi": "locations", # Try locations first "poi": "items", } return mapping.get(node_type, "children") @@ -467,3 +541,106 @@ class SpatialGraph: except Exception as e: logger.error(f"Failed to load spatial graph: {e}") return cls() + + def promote_location(self, location_id: str) -> bool: + """Promote a nested location to its parent's level. + + Makes location_id a sibling of its current parent by moving it to + grandparent's children list. Only works for nested locations. + + Returns: True if successful, False otherwise + """ + node = self.nodes.get(location_id) + if not node or node.node_type != "location": + logger.warning(f"Cannot promote: {location_id} is not a location") + return False + + parent = self.nodes.get(node.parent_id) + if not parent or parent.node_type != "location": + logger.warning(f"Cannot promote: {location_id}'s parent is not a location") + return False + + grandparent = self.nodes.get(parent.parent_id) + if not grandparent: + logger.warning(f"Cannot promote: {location_id} has no grandparent") + return False + + # Remove from parent's children + parent.children.remove(location_id) + + # Add to grandparent's children + grandparent.children.append(location_id) + + # Update node's parent_id + node.parent_id = parent.parent_id + + logger.info(f"Promoted {location_id} from {parent.id} to {grandparent.id}") + return True + + def demote_location(self, location_id: str, new_parent_id: str) -> bool: + """Demote a location by moving it under a new parent location. + + Moves location_id from its current parent to a new parent location. + This effectively nests it under the new parent. + + Args: + location_id: The location to demote + new_parent_id: The new parent location (must be a location node) + + Returns: True if successful, False otherwise + """ + node = self.nodes.get(location_id) + if not node or node.node_type != "location": + logger.warning(f"Cannot demote: {location_id} is not a location") + return False + + new_parent = self.nodes.get(new_parent_id) + if not new_parent or new_parent.node_type != "location": + logger.warning(f"Cannot demote: {new_parent_id} is not a location") + return False + + if location_id == new_parent_id: + logger.warning(f"Cannot demote: location cannot be its own parent") + return False + + # Check for circular dependencies + if self._has_ancestor(location_id, new_parent_id): + logger.warning(f"Cannot demote: {new_parent_id} is a descendant of {location_id}") + return False + + # Remove from current parent + old_parent = self.nodes.get(node.parent_id) + if old_parent: + old_parent.children.remove(location_id) + + # Add to new parent + new_parent.children.append(location_id) + node.parent_id = new_parent_id + + logger.info(f"Demoted {location_id} to child of {new_parent_id}") + return True + + def _has_ancestor(self, node_id: str, ancestor_id: str) -> bool: + """Check if ancestor_id is an ancestor of node_id.""" + current = self.nodes.get(node_id) + while current and current.parent_id: + if current.parent_id == ancestor_id: + return True + current = self.nodes.get(current.parent_id) + return False + + def get_path_to_node(self, node_id: str) -> List[str]: + """Get full path from root to node as list of names. + + Example: ['The Kingdom', 'The Village', 'Tavern Area', 'Tavern Bar'] + """ + path = [] + current_id = node_id + while current_id: + node = self.nodes.get(current_id) + if node: + path.insert(0, f"{node.name} ({node.id})") + current_id = node.parent_id + else: + break + return path diff --git a/src/omnia/tools/scenario_builder.py b/src/omnia/tools/scenario_builder.py new file mode 100644 index 0000000..2ad2d84 --- /dev/null +++ b/src/omnia/tools/scenario_builder.py @@ -0,0 +1,1120 @@ +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from langchain_core.messages import HumanMessage, SystemMessage +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Prompt, Confirm +from rich.table import Table +from rich.tree import Tree +from rich.syntax import Syntax + + +console = Console() +logger = logging.getLogger(__name__) + + +# Default templates and structures +DEFAULT_SCENARIO = { + "scenario": { + "id": "", + "title": "", + "description": "", + "player_id": "player", + "world_time": "2026-04-14 12:00", + "location": "Village", + }, + "entities": [], + "spatial_graph": { + "world": { + "id": "world", + "name": "The World", + "description": "A vast and mysterious realm.", + "regions": [], + } + }, +} + +DEFAULT_ENTITY = { + "id": "", + "name": "", + "traits": [], + "stats": {}, + "voice_sample": "", + "current_mood": "Neutral", + "memories": [], + "metadata": {"location": "", "spatial_descriptor": ""}, +} + +DEFAULT_LOCATION = {"id": "", "name": "", "description": "", "pois": []} + +DEFAULT_POI = {"id": "", "name": "", "description": "", "connections": []} + +DEFAULT_CONNECTION = { + "target": "", + "portal": "", + "portal_state_descriptor": "open", + "vision_prop": 5, + "sound_prop": 5, + "bidirectional": True, +} + + +class ScenarioBuilder: + """Interactive scenario builder with rich CLI interface.""" + + def __init__(self): + self.scenario = json.loads(json.dumps(DEFAULT_SCENARIO)) + self.entities_dict: Dict[str, Dict[str, Any]] = {} + + def run(self) -> None: + """Start the interactive scenario builder.""" + console.clear() + self._show_welcome() + + while True: + choice = self._show_main_menu() + + if choice == "1": + self._edit_scenario_metadata() + elif choice == "2": + self._manage_entities() + elif choice == "3": + self._manage_spatial_graph() + elif choice == "4": + self._preview_scenario() + elif choice == "5": + if self._save_scenario(): + break + elif choice == "6": + if Confirm.ask("[yellow]Exit without saving?"): + console.print("[red]✗ Scenario discarded.[/red]") + break + + def _show_welcome(self) -> None: + """Display welcome message.""" + welcome_text = """ +[bold cyan]╔════════════════════════════════════════════╗[/bold cyan] +[bold cyan]║[/bold cyan] [bold]Omnia Scenario Builder[/bold] [bold cyan]║[/bold cyan] +[bold cyan]║[/bold cyan] Create interactive game scenarios [bold cyan]║[/bold cyan] +[bold cyan]╚════════════════════════════════════════════╝[/bold cyan] + +Create compelling scenarios with entities, locations, and spatial connections. +""" + console.print(welcome_text) + + def _show_main_menu(self) -> str: + """Show main menu and return user choice.""" + console.print("\n[bold]Main Menu[/bold]") + console.print( + "1. [cyan]Scenario Metadata[/cyan] - Title, description, world time" + ) + console.print("2. [cyan]Manage Entities[/cyan] - Add, edit, remove characters") + console.print("3. [cyan]Spatial Graph[/cyan] - Locations, POIs, connections") + console.print("4. [cyan]Preview[/cyan] - View complete scenario") + console.print("5. [cyan]Save Scenario[/cyan] - Save to JSON file") + console.print("6. [cyan]Exit[/cyan] - Quit without saving") + + choice = Prompt.ask("\nChoice", choices=["1", "2", "3", "4", "5", "6"]) + return choice + + # ═══════════════════════════════════════════════════════════════════════════ + # SCENARIO METADATA + # ═══════════════════════════════════════════════════════════════════════════ + + def _edit_scenario_metadata(self) -> None: + """Edit scenario metadata (title, description, etc.).""" + console.clear() + console.print("[bold cyan]Scenario Metadata[/bold cyan]\n") + + meta = self.scenario["scenario"] + + # Scenario ID + meta["id"] = Prompt.ask("Scenario ID", default=meta["id"], console=console) + + # Title + meta["title"] = Prompt.ask("Title", default=meta["title"], console=console) + + # Description + console.print("\nEnter description (press Enter twice to finish):") + lines = [] + while True: + line = input() + if not line: + if lines: + break + else: + lines.append(line) + if lines: + meta["description"] = "\n".join(lines) + + # World time + meta["world_time"] = Prompt.ask( + "World time (YYYY-MM-DD HH:MM)", default=meta["world_time"], console=console + ) + + # Player ID + meta["player_id"] = Prompt.ask( + "Player entity ID", default=meta["player_id"], console=console + ) + + # Location + meta["location"] = Prompt.ask( + "Starting location", default=meta["location"], console=console + ) + + console.print("[green]✓ Scenario metadata updated[/green]") + + # ═══════════════════════════════════════════════════════════════════════════ + # ENTITIES MANAGEMENT + # ═══════════════════════════════════════════════════════════════════════════ + + def _manage_entities(self) -> None: + """Manage entities menu.""" + while True: + console.clear() + console.print("[bold cyan]Manage Entities[/bold cyan]\n") + + self._list_entities() + + console.print("\n1. [cyan]Add Entity[/cyan]") + console.print("2. [cyan]Edit Entity[/cyan]") + console.print("3. [cyan]Remove Entity[/cyan]") + console.print("4. [cyan]Generate Entity (LLM)[/cyan]") + console.print("5. [cyan]Back[/cyan]") + + choice = Prompt.ask("Choice", choices=["1", "2", "3", "4", "5"]) + + if choice == "1": + self._add_entity() + elif choice == "2": + self._edit_entity() + elif choice == "3": + self._remove_entity() + elif choice == "4": + self._generate_entity() + elif choice == "5": + break + + def _list_entities(self) -> None: + """Display table of current entities.""" + if not self.scenario["entities"]: + console.print("[yellow]No entities yet[/yellow]") + return + + table = Table(title="Entities") + table.add_column("ID", style="cyan") + table.add_column("Name", style="green") + table.add_column("Traits", style="magenta") + table.add_column("Location", style="blue") + + for entity in self.scenario["entities"]: + traits_str = ", ".join(entity.get("traits", []))[:30] + location = entity.get("metadata", {}).get("location", "—") + table.add_row(entity["id"], entity["name"], traits_str, location) + + console.print(table) + + def _add_entity(self) -> None: + """Add a new entity.""" + console.clear() + console.print("[bold cyan]Add New Entity[/bold cyan]\n") + + entity = json.loads(json.dumps(DEFAULT_ENTITY)) + + entity["id"] = Prompt.ask("Entity ID (unique)") + entity["name"] = Prompt.ask("Name") + + # Traits + console.print("Enter traits (comma-separated):") + traits_str = input() + entity["traits"] = [t.strip() for t in traits_str.split(",") if t.strip()] + + # Stats + if Confirm.ask("Add stats?"): + while True: + stat_name = Prompt.ask("Stat name (or 'done')") + if stat_name.lower() == "done": + break + stat_value = Prompt.ask(f"Value for {stat_name}", default="10") + try: + entity["stats"][stat_name] = int(stat_value) + except ValueError: + console.print("[red]Invalid value, skipped[/red]") + + # Voice sample + entity["voice_sample"] = Prompt.ask( + "Voice sample (characteristic quote)", default="[Add voice sample]" + ) + + # Mood + entity["current_mood"] = Prompt.ask("Current mood", default="Neutral") + + # Metadata + entity["metadata"]["location"] = Prompt.ask("Starting location ID") + entity["metadata"]["spatial_descriptor"] = Prompt.ask( + "Spatial descriptor (position in location)" + ) + + self.scenario["entities"].append(entity) + console.print("[green]✓ Entity added[/green]") + + def _edit_entity(self) -> None: + """Edit an existing entity.""" + if not self.scenario["entities"]: + console.print("[yellow]No entities to edit[/yellow]") + return + + entity_ids = [e["id"] for e in self.scenario["entities"]] + entity_id = Prompt.ask("Entity ID to edit", choices=entity_ids) + + entity = next(e for e in self.scenario["entities"] if e["id"] == entity_id) + + console.print("\nEdit fields (leave blank to skip):") + + # Quick edit fields + if new_name := Prompt.ask("Name", default=entity["name"]): + entity["name"] = new_name + + if new_mood := Prompt.ask("Mood", default=entity["current_mood"]): + entity["current_mood"] = new_mood + + if new_descriptor := Prompt.ask( + "Spatial descriptor", default=entity["metadata"]["spatial_descriptor"] + ): + entity["metadata"]["spatial_descriptor"] = new_descriptor + + console.print("[green]✓ Entity updated[/green]") + + def _remove_entity(self) -> None: + """Remove an entity.""" + if not self.scenario["entities"]: + console.print("[yellow]No entities to remove[/yellow]") + return + + entity_ids = [e["id"] for e in self.scenario["entities"]] + entity_id = Prompt.ask("Entity ID to remove", choices=entity_ids) + + if Confirm.ask(f"Remove {entity_id}?", default=False): + self.scenario["entities"] = [ + e for e in self.scenario["entities"] if e["id"] != entity_id + ] + console.print("[green]✓ Entity removed[/green]") + + def _request_llm_json( + self, system_prompt: str, user_prompt: str + ) -> Optional[Dict[str, Any]]: + """Invoke the LLM and parse a JSON object response.""" + from omnia.llm_runtime import invoke_llm + + messages = [ + SystemMessage(content=system_prompt), + HumanMessage(content=user_prompt), + ] + response_text = invoke_llm(messages) + logger.debug("Scenario builder raw LLM response: %s", response_text) + + try: + return json.loads(response_text) + except json.JSONDecodeError: + console.print("[red]✗ LLM response was not valid JSON. Try again.[/red]") + logger.error("Scenario builder invalid JSON response: %s", response_text) + return None + + def _get_all_location_ids(self) -> List[str]: + world = self.scenario["spatial_graph"]["world"] + return [loc["id"] for loc in self._get_all_locations_recursive(world)] + + def _select_location_from_all(self) -> Optional[Dict[str, Any]]: + world = self.scenario["spatial_graph"]["world"] + all_locations = self._get_all_locations_recursive(world) + + if not all_locations: + console.print("[red]No locations exist[/red]") + return None + + location_choices = [f"{loc['name']} ({loc['id']})" for loc in all_locations] + location_choice = Prompt.ask("Location", choices=location_choices) + return next( + loc + for loc in all_locations + if f"{loc['name']} ({loc['id']})" == location_choice + ) + + def _generate_entity(self) -> None: + """Generate a new entity using the LLM.""" + console.clear() + console.print("[bold cyan]Generate Entity (LLM)[/bold cyan]\n") + + seed = Prompt.ask("Describe the entity to generate") + meta = self.scenario["scenario"] + location_ids = self._get_all_location_ids() + + system_prompt = """ +You are helping build a game scenario. +Return ONLY a single-line JSON object with keys: +id, name, traits, stats, voice_sample, current_mood, metadata. +- id: short snake_case identifier +- traits: array of 3-6 short traits +- stats: object with integer values (1-20) +- metadata: object with keys "location" and "spatial_descriptor" +No commentary. ASCII only. +""".strip() + + user_prompt = f""" +Scenario title: {meta.get("title")} +Scenario description: {meta.get("description")} +Available location IDs: {", ".join(location_ids) if location_ids else "None"} +Entity request: {seed} +""".strip() + + payload = self._request_llm_json(system_prompt, user_prompt) + if not payload: + return + + entity = json.loads(json.dumps(DEFAULT_ENTITY)) + entity["id"] = str(payload.get("id", "")).strip() + entity["name"] = str(payload.get("name", "")).strip() + + traits = payload.get("traits", []) + if isinstance(traits, str): + traits = [t.strip() for t in traits.split(",") if t.strip()] + if isinstance(traits, list): + entity["traits"] = [str(t).strip() for t in traits if str(t).strip()] + + stats = payload.get("stats", {}) + if isinstance(stats, dict): + for key, value in stats.items(): + key = str(key).strip() + if not key: + continue + try: + entity["stats"][key] = int(value) + except (TypeError, ValueError): + continue + + voice_sample = payload.get("voice_sample", "") + if isinstance(voice_sample, str) and voice_sample.strip(): + entity["voice_sample"] = voice_sample.strip() + + current_mood = payload.get("current_mood", "") + if isinstance(current_mood, str) and current_mood.strip(): + entity["current_mood"] = current_mood.strip() + + metadata = payload.get("metadata", {}) + if isinstance(metadata, dict): + location = metadata.get("location", "") + spatial_descriptor = metadata.get("spatial_descriptor", "") + if isinstance(location, str): + entity["metadata"]["location"] = location.strip() + if isinstance(spatial_descriptor, str): + entity["metadata"]["spatial_descriptor"] = spatial_descriptor.strip() + + existing_ids = {e["id"] for e in self.scenario["entities"]} + while not entity["id"] or entity["id"] in existing_ids: + if entity["id"] in existing_ids: + console.print( + f"[red]✗ Entity ID '{entity['id']}' already exists[/red]" + ) + entity["id"] = Prompt.ask("Entity ID (unique)", default=entity["id"]) + + if not entity["name"]: + entity["name"] = Prompt.ask("Name") + + if location_ids and entity["metadata"]["location"] not in location_ids: + console.print( + "[yellow]LLM returned an unknown location. Please choose.[/yellow]" + ) + entity["metadata"]["location"] = Prompt.ask( + "Entity location", choices=location_ids + ) + + console.print("\nGenerated entity:") + syntax = Syntax(json.dumps(entity, indent=2), "json", theme="monokai") + console.print(syntax) + + if Confirm.ask("Add this entity?", default=True): + self.scenario["entities"].append(entity) + console.print("[green]✓ Entity added[/green]") + else: + console.print("[yellow]Entity discarded[/yellow]") + + # ═══════════════════════════════════════════════════════════════════════════ + # SPATIAL GRAPH + # ═══════════════════════════════════════════════════════════════════════════ + + def _manage_spatial_graph(self) -> None: + """Manage spatial graph (locations, POIs, connections).""" + while True: + console.clear() + console.print("[bold cyan]Spatial Graph[/bold cyan]\n") + + self._show_spatial_tree() + + console.print("\n1. [cyan]Manage Regions[/cyan]") + console.print("2. [cyan]Add Location[/cyan]") + console.print( + "3. [cyan]Add Nested Location[/cyan] (under existing location)" + ) + console.print("4. [cyan]Add POI to Location[/cyan]") + console.print("5. [cyan]Add Connection[/cyan]") + console.print("6. [cyan]Generate Location (LLM)[/cyan]") + console.print("7. [cyan]Generate POI (LLM)[/cyan]") + console.print("8. [cyan]Back[/cyan]") + + choice = Prompt.ask( + "Choice", choices=["1", "2", "3", "4", "5", "6", "7", "8"] + ) + + if choice == "1": + self._manage_regions() + elif choice == "2": + self._add_location() + elif choice == "3": + self._add_nested_location() + elif choice == "4": + self._add_poi() + elif choice == "5": + self._add_connection() + elif choice == "6": + self._generate_location() + elif choice == "7": + self._generate_poi() + elif choice == "8": + break + + def _show_spatial_tree(self) -> None: + """Display spatial graph as tree.""" + world = self.scenario["spatial_graph"]["world"] + tree = Tree(f"🌍 {world['name']}") + + regions = world.get("regions", []) + for region in regions: + region_node = tree.add(f"📍 {region['name']} ({region['id']})") + locations = region.get("locations", []) + for location in locations: + loc_node = region_node.add(f"📍 {location['name']} ({location['id']})") + pois = location.get("pois", []) + for poi in pois: + conn_count = len(poi.get("connections", [])) + loc_node.add( + f"📌 {poi['name']} ({poi['id']}) [{conn_count} connections]" + ) + + console.print(tree) + + def _manage_regions(self) -> None: + """Manage regions in the spatial graph.""" + while True: + console.clear() + console.print("[bold cyan]Manage Regions[/bold cyan]\n") + + self._list_regions() + + console.print("\n1. [cyan]Add Region[/cyan]") + console.print("2. [cyan]Edit Region[/cyan]") + console.print("3. [cyan]Remove Region[/cyan]") + console.print("4. [cyan]Back[/cyan]") + + choice = Prompt.ask("Choice", choices=["1", "2", "3", "4"]) + + if choice == "1": + self._add_region() + elif choice == "2": + self._edit_region() + elif choice == "3": + self._remove_region() + elif choice == "4": + break + + def _list_regions(self) -> None: + """Display table of current regions.""" + world = self.scenario["spatial_graph"]["world"] + regions = world.get("regions", []) + + if not regions: + console.print("[yellow]No regions yet[/yellow]") + return + + table = Table(title="Regions") + table.add_column("ID", style="cyan") + table.add_column("Name", style="green") + table.add_column("Locations", style="magenta") + + for region in regions: + locations_count = len(region.get("locations", [])) + table.add_row(region.get("id", ""), region.get("name", ""), str(locations_count)) + + console.print(table) + + def _select_region(self) -> Optional[Dict[str, Any]]: + world = self.scenario["spatial_graph"]["world"] + regions = world.get("regions", []) + + if not regions: + console.print("[red]No regions exist[/red]") + return None + + region_choices = [f"{r['name']} ({r['id']})" for r in regions] + region_choice = Prompt.ask("Region", choices=region_choices) + return next( + r for r in regions if f"{r['name']} ({r['id']})" == region_choice + ) + + def _add_region(self) -> None: + """Add a new region to the spatial graph.""" + console.clear() + console.print("[bold cyan]Add Region[/bold cyan]\n") + + world = self.scenario["spatial_graph"]["world"] + regions = world.get("regions", []) + existing_ids = {r.get("id") for r in regions} + existing_names = {r.get("name") for r in regions} + + region_id = Prompt.ask("Region ID") + while not region_id or region_id in existing_ids: + if region_id in existing_ids: + console.print(f"[red]✗ Region ID '{region_id}' already exists[/red]") + region_id = Prompt.ask("Region ID") + + region_name = Prompt.ask("Region name") + while not region_name or region_name in existing_names: + if region_name in existing_names: + console.print( + f"[red]✗ Region name '{region_name}' already exists[/red]" + ) + region_name = Prompt.ask("Region name") + + region_description = Prompt.ask("Region description", default="A region") + + region = { + "id": region_id, + "name": region_name, + "description": region_description, + "locations": [], + } + regions.append(region) + world["regions"] = regions + + console.print("[green]✓ Region added[/green]") + + def _edit_region(self) -> None: + """Edit an existing region.""" + region = self._select_region() + if not region: + return + + world = self.scenario["spatial_graph"]["world"] + regions = world.get("regions", []) + existing_ids = {r.get("id") for r in regions if r is not region} + existing_names = {r.get("name") for r in regions if r is not region} + + new_id = Prompt.ask("Region ID", default=region.get("id", "")) + while not new_id or new_id in existing_ids: + if new_id in existing_ids: + console.print(f"[red]✗ Region ID '{new_id}' already exists[/red]") + new_id = Prompt.ask("Region ID", default=region.get("id", "")) + + new_name = Prompt.ask("Region name", default=region.get("name", "")) + while not new_name or new_name in existing_names: + if new_name in existing_names: + console.print(f"[red]✗ Region name '{new_name}' already exists[/red]") + new_name = Prompt.ask("Region name", default=region.get("name", "")) + + new_description = Prompt.ask( + "Region description", default=region.get("description", "") + ) + + region["id"] = new_id + region["name"] = new_name + region["description"] = new_description + + console.print("[green]✓ Region updated[/green]") + + def _remove_region(self) -> None: + """Remove a region from the spatial graph.""" + region = self._select_region() + if not region: + return + + region_name = region.get("name", "") + region_id = region.get("id", "") + if not Confirm.ask( + f"Remove region {region_name} ({region_id}) and all nested locations?", + default=False, + ): + console.print("[yellow]Remove cancelled[/yellow]") + return + + world = self.scenario["spatial_graph"]["world"] + world["regions"] = [r for r in world.get("regions", []) if r is not region] + console.print("[green]✓ Region removed[/green]") + + def _add_location(self) -> None: + """Add a new location to the spatial graph.""" + console.clear() + console.print("[bold cyan]Add New Location[/bold cyan]\n") + + # Get or create region + world = self.scenario["spatial_graph"]["world"] + regions = world.get("regions", []) + + if not regions: + console.print("[yellow]No regions yet. Creating default region...[/yellow]") + region = { + "id": "region_1", + "name": "Main Region", + "description": "A region", + "locations": [], + } + world["regions"] = [region] + else: + region_names = [r["name"] for r in regions] + region_name = Prompt.ask("Region", choices=region_names) + region = next(r for r in regions if r["name"] == region_name) + + location = json.loads(json.dumps(DEFAULT_LOCATION)) + location["id"] = Prompt.ask("Location ID") + location["name"] = Prompt.ask("Location name") + location["description"] = Prompt.ask("Location description") + + region.setdefault("locations", []).append(location) + console.print("[green]✓ Location added[/green]") + + def _generate_location(self) -> None: + """Generate a new location using the LLM.""" + console.clear() + console.print("[bold cyan]Generate Location (LLM)[/bold cyan]\n") + + world = self.scenario["spatial_graph"]["world"] + regions = world.get("regions", []) + + if not regions: + console.print("[yellow]No regions yet. Creating default region...[/yellow]") + region = { + "id": "region_1", + "name": "Main Region", + "description": "A region", + "locations": [], + } + world["regions"] = [region] + else: + region_names = [r["name"] for r in regions] + region_name = Prompt.ask("Region", choices=region_names) + region = next(r for r in regions if r["name"] == region_name) + + seed = Prompt.ask("Describe the location to generate") + meta = self.scenario["scenario"] + existing_location_ids = self._get_all_location_ids() + + system_prompt = """ +You are helping build a game scenario. +Return ONLY a single-line JSON object with keys: +id, name, description, pois. +- id: short snake_case identifier +- description: 1-3 sentences +- pois: array of objects with keys id, name, description +No commentary. ASCII only. +""".strip() + + user_prompt = f""" +Scenario title: {meta.get("title")} +Scenario description: {meta.get("description")} +Existing location IDs: {", ".join(existing_location_ids) if existing_location_ids else "None"} +Location request: {seed} +""".strip() + + payload = self._request_llm_json(system_prompt, user_prompt) + if not payload: + return + + location = json.loads(json.dumps(DEFAULT_LOCATION)) + location["id"] = str(payload.get("id", "")).strip() + location["name"] = str(payload.get("name", "")).strip() + location["description"] = str(payload.get("description", "")).strip() + + while not location["id"] or location["id"] in existing_location_ids: + if location["id"] in existing_location_ids: + console.print( + f"[red]✗ Location ID '{location['id']}' already exists[/red]" + ) + location["id"] = Prompt.ask("Location ID", default=location["id"]) + + if not location["name"]: + location["name"] = Prompt.ask("Location name") + + if not location["description"]: + location["description"] = Prompt.ask("Location description") + + pois_payload = payload.get("pois", []) + if isinstance(pois_payload, list): + seen_poi_ids = set() + for poi_payload in pois_payload: + if not isinstance(poi_payload, dict): + continue + poi = json.loads(json.dumps(DEFAULT_POI)) + poi["id"] = str(poi_payload.get("id", "")).strip() + poi["name"] = str(poi_payload.get("name", "")).strip() + poi["description"] = str(poi_payload.get("description", "")).strip() + if not poi["id"] or not poi["name"] or poi["id"] in seen_poi_ids: + continue + seen_poi_ids.add(poi["id"]) + location["pois"].append(poi) + + console.print("\nGenerated location:") + syntax = Syntax(json.dumps(location, indent=2), "json", theme="monokai") + console.print(syntax) + + if Confirm.ask("Add this location?", default=True): + region.setdefault("locations", []).append(location) + console.print("[green]✓ Location added[/green]") + else: + console.print("[yellow]Location discarded[/yellow]") + + def _add_nested_location(self) -> None: + """Add a nested location under an existing location.""" + console.clear() + console.print("[bold cyan]Add Nested Location[/bold cyan]\n") + + # Get all locations recursively + all_locations = self._get_all_locations_recursive( + self.scenario["spatial_graph"]["world"] + ) + + if not all_locations: + console.print("[red]No locations exist to nest under[/red]") + return + + # Show available parent locations + location_choices = [f"{loc['name']} ({loc['id']})" for loc in all_locations] + console.print("Select parent location:") + parent_name = Prompt.ask("Parent location", choices=location_choices) + + # Find the selected parent location (need to search recursively) + parent_location = next( + (l for l in all_locations if f"{l['name']} ({l['id']})" == parent_name), + None, + ) + + if not parent_location: + console.print("[red]Parent location not found[/red]") + return + + # Create nested location + location = json.loads(json.dumps(DEFAULT_LOCATION)) + location["id"] = Prompt.ask("Location ID") + location["name"] = Prompt.ask("Location name") + location["description"] = Prompt.ask("Location description") + + # Add to parent's locations + parent_location.setdefault("locations", []).append(location) + console.print( + f"[green]✓ Nested location added under {parent_location['name']}[/green]" + ) + + def _get_all_locations_recursive( + self, node: Dict[str, Any] + ) -> List[Dict[str, Any]]: + """Get all locations recursively from spatial graph.""" + locations = [] + + # Get locations from regions + for region in node.get("regions", []): + for location in region.get("locations", []): + locations.append(location) + # Recursively get nested locations + locations.extend(self._get_nested_locations(location)) + + return locations + + def _get_nested_locations(self, location: Dict[str, Any]) -> List[Dict[str, Any]]: + """Get all nested locations recursively.""" + nested = [] + for nested_loc in location.get("locations", []): + nested.append(nested_loc) + # Recursively get deeper nested locations + nested.extend(self._get_nested_locations(nested_loc)) + return nested + + def _add_poi(self) -> None: + """Add a point of interest to a location.""" + console.clear() + console.print("[bold cyan]Add POI[/bold cyan]\n") + + world = self.scenario["spatial_graph"]["world"] + regions = world.get("regions", []) + + if not regions: + console.print("[red]No regions exist[/red]") + return + + # Select region + region_names = [r["name"] for r in regions] + region_name = Prompt.ask("Region", choices=region_names) + region = next(r for r in regions if r["name"] == region_name) + + # Select location + locations = region.get("locations", []) + if not locations: + console.print("[red]No locations in this region[/red]") + return + + location_names = [l["name"] for l in locations] + location_name = Prompt.ask("Location", choices=location_names) + location = next(l for l in locations if l["name"] == location_name) + + # Add POI + poi = json.loads(json.dumps(DEFAULT_POI)) + poi["id"] = Prompt.ask("POI ID") + poi["name"] = Prompt.ask("POI name") + poi["description"] = Prompt.ask("POI description") + + location.setdefault("pois", []).append(poi) + console.print("[green]✓ POI added[/green]") + + def _generate_poi(self) -> None: + """Generate a point of interest using the LLM.""" + console.clear() + console.print("[bold cyan]Generate POI (LLM)[/bold cyan]\n") + + location = self._select_location_from_all() + if not location: + return + + seed = Prompt.ask("Describe the POI to generate") + meta = self.scenario["scenario"] + + system_prompt = """ +You are helping build a game scenario. +Return ONLY a single-line JSON object with keys: +id, name, description. +- id: short snake_case identifier +- description: 1-2 sentences +No commentary. ASCII only. +""".strip() + + user_prompt = f""" +Scenario title: {meta.get("title")} +Scenario description: {meta.get("description")} +Location name: {location.get("name")} +Location description: {location.get("description")} +POI request: {seed} +""".strip() + + payload = self._request_llm_json(system_prompt, user_prompt) + if not payload: + return + + poi = json.loads(json.dumps(DEFAULT_POI)) + poi["id"] = str(payload.get("id", "")).strip() + poi["name"] = str(payload.get("name", "")).strip() + poi["description"] = str(payload.get("description", "")).strip() + + existing_ids = {p["id"] for p in location.get("pois", [])} + while not poi["id"] or poi["id"] in existing_ids: + if poi["id"] in existing_ids: + console.print(f"[red]✗ POI ID '{poi['id']}' already exists[/red]") + poi["id"] = Prompt.ask("POI ID", default=poi["id"]) + + if not poi["name"]: + poi["name"] = Prompt.ask("POI name") + + if not poi["description"]: + poi["description"] = Prompt.ask("POI description") + + console.print("\nGenerated POI:") + syntax = Syntax(json.dumps(poi, indent=2), "json", theme="monokai") + console.print(syntax) + + if Confirm.ask("Add this POI?", default=True): + location.setdefault("pois", []).append(poi) + console.print("[green]✓ POI added[/green]") + else: + console.print("[yellow]POI discarded[/yellow]") + + def _add_connection(self) -> None: + """Add connection between POIs.""" + console.clear() + console.print("[bold cyan]Add Connection[/bold cyan]\n") + + world = self.scenario["spatial_graph"]["world"] + all_pois = self._get_all_pois(world) + + if len(all_pois) < 2: + console.print("[red]Need at least 2 POIs to create connections[/red]") + return + + poi_choices = [f"{poi['name']} ({poi['id']})" for poi in all_pois] + + console.print("Source POI:") + source_name = Prompt.ask("From", choices=poi_choices) + source_poi = next( + p for p in all_pois if f"{p['name']} ({p['id']})" == source_name + ) + + console.print("\nTarget POI:") + target_choices = [ + f"{p['name']} ({p['id']})" for p in all_pois if p["id"] != source_poi["id"] + ] + target_name = Prompt.ask("To", choices=target_choices) + target_poi = next( + p for p in all_pois if f"{p['name']} ({p['id']})" == target_name + ) + + # Connection properties + connection = json.loads(json.dumps(DEFAULT_CONNECTION)) + connection["target"] = target_poi["id"] + connection["portal"] = Prompt.ask("Portal name", default="passage") + connection["portal_state_descriptor"] = Prompt.ask( + "Portal state", default="open" + ) + connection["vision_prop"] = int( + Prompt.ask("Vision propagation (0-10)", default="5") + ) + connection["sound_prop"] = int( + Prompt.ask("Sound propagation (0-10)", default="5") + ) + connection["bidirectional"] = Confirm.ask("Bidirectional?", default=True) + + source_poi.setdefault("connections", []).append(connection) + + # Add reverse connection if bidirectional + if connection["bidirectional"]: + reverse_conn = json.loads(json.dumps(DEFAULT_CONNECTION)) + reverse_conn["target"] = source_poi["id"] + reverse_conn["portal"] = connection["portal"] + reverse_conn["portal_state_descriptor"] = connection[ + "portal_state_descriptor" + ] + reverse_conn["vision_prop"] = connection["vision_prop"] + reverse_conn["sound_prop"] = connection["sound_prop"] + reverse_conn["bidirectional"] = True + target_poi.setdefault("connections", []).append(reverse_conn) + + console.print("[green]✓ Connection added[/green]") + + def _get_all_pois(self, world: Dict[str, Any]) -> List[Dict[str, Any]]: + """Get all POIs from the world.""" + pois = [] + for region in world.get("regions", []): + for location in region.get("locations", []): + pois.extend(location.get("pois", [])) + return pois + + # ═══════════════════════════════════════════════════════════════════════════ + # PREVIEW & SAVE + # ═══════════════════════════════════════════════════════════════════════════ + + def _preview_scenario(self) -> None: + """Show a preview of the scenario.""" + console.clear() + + # Summary panel + meta = self.scenario["scenario"] + summary = f""" +[cyan]ID:[/cyan] {meta["id"]} +[cyan]Title:[/cyan] {meta["title"]} +[cyan]Description:[/cyan] {meta["description"][:100]}... +[cyan]World Time:[/cyan] {meta["world_time"]} +[cyan]Player ID:[/cyan] {meta["player_id"]} +""" + console.print(Panel(summary, title="Scenario Summary")) + + # Entities + console.print(f"\n[bold]Entities:[/bold] {len(self.scenario['entities'])}") + for entity in self.scenario["entities"]: + console.print(f" • {entity['name']} ({entity['id']})") + + # Spatial structure + world = self.scenario["spatial_graph"]["world"] + regions = world.get("regions", []) + locations_count = sum(len(r.get("locations", [])) for r in regions) + pois_count = sum( + len(l.get("pois", [])) for r in regions for l in r.get("locations", []) + ) + connections_count = sum( + len(p.get("connections", [])) + for r in regions + for l in r.get("locations", []) + for p in l.get("pois", []) + ) + + spatial_info = f""" +[cyan]Regions:[/cyan] {len(regions)} +[cyan]Locations:[/cyan] {locations_count} +[cyan]POIs:[/cyan] {pois_count} +[cyan]Connections:[/cyan] {connections_count} +""" + console.print(Panel(spatial_info, title="Spatial Graph")) + + # JSON preview + if Confirm.ask("\nShow JSON preview?"): + json_str = json.dumps(self.scenario, indent=2) + syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False) + console.print(syntax) + + Prompt.ask("\nPress Enter to continue") + + def _save_scenario(self) -> bool: + """Save scenario to file.""" + # Validate required fields + if not self.scenario["scenario"]["id"]: + console.print("[red]✗ Scenario ID is required[/red]") + return False + + if not self.scenario["scenario"]["title"]: + console.print("[red]✗ Scenario title is required[/red]") + return False + + if not self.scenario["entities"]: + console.print("[yellow]⚠ Warning: No entities in scenario[/yellow]") + + # Get filename + scenario_id = self.scenario["scenario"]["id"] + default_filename = f"{scenario_id}.json" + + filename = Prompt.ask("Filename", default=default_filename, console=console) + + # Ensure .json extension + if not filename.endswith(".json"): + filename += ".json" + + # Save to scenarios directory + scenarios_dir = Path(__file__).parent.parent.parent / "scenarios" + scenarios_dir.mkdir(exist_ok=True) + + filepath = scenarios_dir / filename + + if filepath.exists() and not Confirm.ask( + f"Overwrite {filename}?", default=False + ): + console.print("[yellow]Save cancelled[/yellow]") + return False + + try: + with open(filepath, "w") as f: + json.dump(self.scenario, f, indent=2) + console.print(f"\n[green]✓ Scenario saved to {filepath}[/green]") + return True + except Exception as e: + console.print(f"[red]✗ Error saving: {e}[/red]") + return False + + +def main() -> None: + """Entry point for scenario builder CLI.""" + try: + builder = ScenarioBuilder() + builder.run() + except KeyboardInterrupt: + console.print("\n[yellow]✗ Interrupted[/yellow]") + except Exception as e: + console.print(f"[red]✗ Error: {e}[/red]") + raise + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index f3e3e1e..7dc29b5 100644 --- a/uv.lock +++ b/uv.lock @@ -1274,7 +1274,7 @@ wheels = [ [[package]] name = "omnia" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "faiss-cpu" }, { name = "langchain" }, @@ -1302,7 +1302,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "omnia", virtual = "." }] +dev = [{ name = "omnia", editable = "." }] [[package]] name = "orjson"