diff --git a/src/authoring_service/constants.py b/src/authoring_service/constants.py index 37fc3a7..a89c1fa 100755 --- a/src/authoring_service/constants.py +++ b/src/authoring_service/constants.py @@ -3,87 +3,35 @@ from __future__ import annotations HIDDEN_CONTENT_TYPES = [ - "tooltip", - "quote", - "whisper", - "poem", - "observation", - "dialogue", - "secret search", - "symbolic fragment", - "ambient memory", - "hidden interaction", -] - -HIDDEN_CONTENT_CLASSES = [ - "fragment", - "memory", - "story material", - "interaction", - "lore", - "system layer", -] - -HIDDEN_SURFACES = [ - "tooltip", "quote", "poem", - "story", - "observatory", - "hidden route", - "search", - "keyboard", - "play", - "dream", - "temporal", - "seasonal", - "loading", - "guestbook", - "terminal", - "layer guide", + "hidden dialogue", + "journal entry", + "rare event", + "loading screen message", + "secret interaction", + "hidden tooltip", + "future z message", + "young z memory fragment", + "sensei chi wisdom entry", + "aphy system message", + "lima note/message", + "dream sequence", + "terminal log", + "fake error message", + "recurring joke", + "seasonal event", + "weather-based event", + "hover message", + "hidden achievement", + "guestbook entry", + "hidden conversation", + "family layer", + "search toast", + "search route", + "keyboard secret", ] -TYPE_ARCHITECTURE = { - "tooltip": {"contentClass": "fragment", "surfaces": ["tooltip"], "observatory": "ambient"}, - "quote": {"contentClass": "fragment", "surfaces": ["quote", "observatory"], "observatory": "ambient"}, - "whisper": {"contentClass": "fragment", "surfaces": ["tooltip", "quote", "observatory"], "observatory": "ambient"}, - "poem": {"contentClass": "fragment", "surfaces": ["poem", "observatory"], "observatory": "ambient"}, - "observation": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"}, - "dialogue": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"}, - "secret search": {"contentClass": "interaction", "surfaces": ["search", "hidden route"], "observatory": "event"}, - "symbolic fragment": {"contentClass": "fragment", "surfaces": ["story", "observatory"], "observatory": "ambient"}, - "ambient memory": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"}, - "hidden interaction": {"contentClass": "interaction", "surfaces": ["hidden route", "keyboard", "play"], "observatory": "event"}, -} - -LEGACY_CONTENT_TYPE_MAP = { - "hidden tooltip": "tooltip", - "hover message": "tooltip", - "loading screen message": "whisper", - "hidden dialogue": "dialogue", - "hidden conversation": "dialogue", - "journal entry": "observation", - "future z message": "ambient memory", - "young z memory fragment": "ambient memory", - "sensei chi wisdom entry": "quote", - "aphy system message": "whisper", - "lima note/message": "whisper", - "dream sequence": "symbolic fragment", - "guestbook entry": "observation", - "terminal log": "hidden interaction", - "fake error message": "hidden interaction", - "recurring joke": "whisper", - "rare event": "hidden interaction", - "secret interaction": "hidden interaction", - "seasonal event": "hidden interaction", - "weather-based event": "hidden interaction", - "hidden achievement": "hidden interaction", - "search toast": "secret search", - "search route": "secret search", - "keyboard secret": "hidden interaction", - "family layer": "observation", -} - CHARACTER_REGISTRY = { "young z": { "id": "young z", @@ -220,6 +168,28 @@ HIDDEN_TONES = ["warm", "funny", "nostalgic", "wise", "strange", "soft", "hopefu HIDDEN_RARITIES = ["common", "uncommon", "rare", "very rare", "seasonal", "timed"] HIDDEN_STORY_MARKERS = ["public", "hidden", "rare", "emotional", "dream-like", "temporal"] HIDDEN_DISCOVERY_STYLES = ["gradual", "direct", "hidden route", "character-led", "dream-like", "temporal"] +CONTENT_CLASSES = [ + "fragment", + "memory", + "story_material", + "interaction", + "lore", + "system_layer", +] +HIDDEN_SURFACES = [ + "tooltip", + "quote", + "story", + "observatory", + "search", + "keyboard", + "play", + "dream", + "temporal", + "hidden route", +] +OBSERVATORY_ROLES = ["ambient", "node", "event", "guide"] +BEAT_EMOTIONS = list(HIDDEN_TONES) HIDDEN_LAYER_DEPTHS = [ { "id": "0", diff --git a/src/authoring_service/hidden.py b/src/authoring_service/hidden.py index 4413607..00b3f87 100755 --- a/src/authoring_service/hidden.py +++ b/src/authoring_service/hidden.py @@ -329,12 +329,107 @@ def normalize_hidden_entry(raw: dict[str, Any], existing: dict[str, Any] | None observatory_role = str(entry.get("observatoryRole") or architecture["observatory"]).strip() entry["observatoryRole"] = observatory_role if observatory_role in {"ambient", "node", "event", "guide"} else architecture["observatory"] entry["canonicalUse"] = str(normalize_character_text_refs(entry.get("canonicalUse") or "")) - for key in ["dialogue", "keyboard"]: - if key in entry: - entry[key] = normalize_character_text_refs(entry[key]) + if entry.get("dialogue") is not None or content_type == "dialogue": + entry["dialogue"] = normalize_dialogue(entry.get("dialogue") or entry.get("content")) + if "keyboard" in entry: + entry["keyboard"] = normalize_character_text_refs(entry["keyboard"]) return entry +def normalize_dialogue(value: Any) -> list[dict[str, str]]: + if not value: + return [] + if isinstance(value, list) and value and isinstance(value[0], dict): + lines = [] + for item in value: + speaker = normalize_character_name(item.get("speaker")) or str(item.get("speaker") or "z").strip() or "z" + text = str(normalize_character_text_refs(item.get("text") or "")).strip() + if text: + line: dict[str, str] = {"speaker": speaker, "text": text} + emotion = str(item.get("emotion") or "").strip() + if emotion: + line["emotion"] = emotion + lines.append(line) + return lines + raw_lines: list[Any] + if isinstance(value, str): + raw_lines = [line for line in value.splitlines() if line.strip()] + elif isinstance(value, list): + raw_lines = value + else: + return [] + lines: list[dict[str, str]] = [] + pending_speaker = "" + for item in raw_lines: + text = str(normalize_character_text_refs(item)).strip() + if not text: + continue + speaker = normalize_character_name(text) + if speaker and len(text.split()) <= 4 and ":" not in text: + pending_speaker = speaker + continue + if ":" in text and not pending_speaker: + name, _, said = text.partition(":") + maybe = normalize_character_name(name.strip()) + if maybe: + pending_speaker = maybe + text = said.strip() + if pending_speaker: + lines.append({"speaker": pending_speaker, "text": text}) + pending_speaker = "" + else: + lines.append({"speaker": "z", "text": text}) + return lines + + +def dialogue_to_flat(dialogue: list[dict[str, str]]) -> list[str]: + flat: list[str] = [] + for line in dialogue: + flat.append(str(line.get("speaker") or "z")) + flat.append(str(line.get("text") or "")) + return flat + + +def dialogue_from_entry(entry: dict[str, Any]) -> list[dict[str, str]]: + dialogue = normalize_dialogue(entry.get("dialogue")) + if dialogue: + return dialogue + content = str(entry.get("content") or "").strip() + if not content: + return [] + return normalize_dialogue(content) + + +def story_items_to_beats(story: dict[str, Any], entries_by_id: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + beats: list[dict[str, Any]] = [] + for index, item in enumerate(story.get("items", [])): + if not isinstance(item, dict): + continue + kind = str(item.get("kind") or "memory").strip().lower() + if kind == "section": + beats.append({ + "id": str(item.get("id") or f"beat-{index + 1:02d}"), + "title": str(item.get("title") or f"Section {index + 1}"), + "location": "", + "sourceEntryId": "", + "dialogue": normalize_dialogue(item.get("content")), + "notes": "", + }) + continue + node_id = str(item.get("id") or "").strip() + entry = entries_by_id.get(node_id, {}) + dialogue = normalize_dialogue(item.get("dialogue")) or dialogue_from_entry(entry) + beats.append({ + "id": f"beat-{index + 1:02d}", + "title": str(item.get("title") or entry.get("title") or f"Beat {index + 1}"), + "location": str(item.get("location") or entry.get("pageLocation") or ""), + "sourceEntryId": node_id, + "dialogue": dialogue, + "notes": str(item.get("notes") or entry.get("notes") or ""), + }) + return beats + + def normalize_hidden_story(raw: dict[str, Any], entries: list[dict[str, Any]] | None = None, existing: dict[str, Any] | None = None) -> dict[str, Any]: today = datetime.now().date().isoformat() story = existing.copy() if existing else {} @@ -372,7 +467,13 @@ def normalize_hidden_story(raw: dict[str, Any], entries: list[dict[str, Any]] | node_id = str(item.get("id") or item.get("memoryId") or "").strip() if node_id and node_id not in nodes and (not ids or node_id in ids): nodes.append(node_id) - items.append({"kind": "memory", "id": node_id}) + memory_item: dict[str, Any] = {"kind": "memory", "id": node_id} + for key in ("title", "location", "notes"): + if item.get(key): + memory_item[key] = str(item.get(key)) + if item.get("dialogue"): + memory_item["dialogue"] = normalize_dialogue(item.get("dialogue")) + items.append(memory_item) characters = normalize_character_list(story.get("characters", [])) symbols = [str(normalize_character_text_refs(item)).strip() for item in story.get("symbols", []) if str(item).strip()] markers = [slugify(str(item)) for item in story.get("markers", []) if str(item).strip()] @@ -391,10 +492,18 @@ def normalize_hidden_story(raw: dict[str, Any], entries: list[dict[str, Any]] | story_id = slugify(str(story.get("id") or f"story-{title}")) if not story_id.startswith("story-"): story_id = f"story-{story_id}" - return { + description = str(normalize_character_text_refs(story.get("description") or "")) + migrated = bool(story.get("migrated")) or "migrated from old" in description.lower() + game_raw = story.get("game") if isinstance(story.get("game"), dict) else {} + game = { + "targetEngine": str(game_raw.get("targetEngine") or "generic-2d"), + "estimatedMinutes": max(1, int(game_raw.get("estimatedMinutes") or max(1, len(items)))), + "tags": normalise_tags(game_raw.get("tags", [])), + } + normalized_story = { "id": story_id, "title": title, - "description": str(normalize_character_text_refs(story.get("description") or "")), + "description": description, "tone": str(story.get("tone") or "warm"), "characters": characters, "symbols": symbols, @@ -405,9 +514,14 @@ def normalize_hidden_story(raw: dict[str, Any], entries: list[dict[str, Any]] | "unlockConditions": [str(normalize_character_text_refs(item)).strip() for item in story.get("unlockConditions", []) if str(item).strip()], "hidden": bool(story.get("hidden", False)), "markers": markers, + "migrated": migrated, + "game": game, "createdDate": str(story.get("createdDate") or today), "modifiedDate": today, } + entries_by_id = {entry["id"]: entry for entry in entries or []} + normalized_story["beats"] = story_items_to_beats(normalized_story, entries_by_id) + return normalized_story def migrate_hidden_stories(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -431,10 +545,14 @@ def migrate_hidden_stories(entries: list[dict[str, Any]]) -> list[dict[str, Any] "unlockConditions": [], "hidden": False, "markers": ["emotional"], + "migrated": True, + "items": [], + "game": {"targetEngine": "generic-2d", "estimatedMinutes": 5, "tags": []}, } story = story_groups[key] if entry["id"] not in story["nodes"]: story["nodes"].append(entry["id"]) + story["items"].append({"kind": "memory", "id": entry["id"]}) for character in entry.get("characters", []): if character not in story["characters"]: story["characters"].append(character) @@ -508,10 +626,101 @@ def load_hidden_store() -> dict[str, Any]: "layers": HIDDEN_LAYER_DEPTHS, "entries": normalized, "stories": normalized_stories, + "migrationHints": hidden_migration_hints(normalized, normalized_stories), "recommendations": hidden_architecture_recommendations(), } +def hidden_migration_hints(entries: list[dict[str, Any]], stories: list[dict[str, Any]]) -> dict[str, Any]: + missing_class = [entry["id"] for entry in entries if not entry.get("contentClass")] + migrated_stories = [story["id"] for story in stories if story.get("migrated")] + empty_beats = [story["id"] for story in stories if not story.get("beats")] + return { + "missingContentClass": missing_class[:30], + "migratedStoryIds": migrated_stories, + "storiesWithoutBeats": empty_beats[:30], + "summary": { + "migratedStoryCount": len(migrated_stories), + "emptyBeatStoryCount": len(empty_beats), + }, + } + + +def resolve_character_for_export(character_id: str) -> dict[str, Any]: + config = CHARACTER_REGISTRY.get(character_id, {}) + return { + "id": character_id, + "displayLabel": config.get("displayLabel", character_id), + "avatar": config.get("avatar", ""), + "territoryColor": config.get("territoryColor", "#d3a64d"), + "glow": config.get("glow", "#f0b85c"), + } + + +def export_game_stories( + stories: list[dict[str, Any]], + entries: list[dict[str, Any]], + story_id: str | None = None, + export_all: bool = False, +) -> dict[str, Any]: + entries_by_id = {entry["id"]: entry for entry in entries} + selected = stories + if story_id: + selected = [story for story in stories if story.get("id") == story_id] + elif not export_all: + selected = [story for story in stories if not story.get("migrated")] + exported = [] + for story in selected: + beats = story.get("beats") or story_items_to_beats(story, entries_by_id) + cast = list(story.get("characters", [])) + for beat in beats: + for line in beat.get("dialogue", []): + speaker = str(line.get("speaker") or "").strip() + if speaker and speaker not in cast: + cast.append(speaker) + exported.append({ + "id": story.get("id"), + "title": story.get("title"), + "description": story.get("description"), + "tone": story.get("tone"), + "characters": [resolve_character_for_export(character) for character in cast], + "game": story.get("game", {}), + "beats": beats, + }) + return { + "schemaVersion": 3, + "targetEngine": "generic-2d", + "exportedAt": datetime.now().isoformat(timespec="seconds"), + "stories": exported, + } + + +def list_fragment_entries( + entries: list[dict[str, Any]], + *, + content_class: str = "", + surface: str = "", + query: str = "", +) -> list[dict[str, Any]]: + results = [] + lowered_query = query.strip().lower() + for entry in entries: + if content_class and entry.get("contentClass") != content_class: + continue + if surface and surface not in entry.get("surfaces", []): + continue + if lowered_query: + haystack = " ".join([ + entry.get("title", ""), + entry.get("content", ""), + " ".join(entry.get("tags", [])), + ]).lower() + if lowered_query not in haystack: + continue + results.append(entry) + return results + + def validate_hidden_integrity(entries: list[dict[str, Any]], stories: list[dict[str, Any]] | None = None) -> dict[str, Any]: ids = {entry["id"] for entry in entries} unknown_characters = [] @@ -589,7 +798,9 @@ def generated_hidden_content_block(entries: list[dict[str, Any]]) -> str: lore = { "quotes": quote_like, "conversations": [ - entry.get("dialogue") if isinstance(entry.get("dialogue"), list) else [line for line in str(entry.get("content", "")).splitlines() if line.strip()] + dialogue_to_flat(normalize_dialogue(entry.get("dialogue") or entry.get("content"))) + if isinstance(entry.get("dialogue"), list) and entry.get("dialogue") and isinstance(entry["dialogue"][0], dict) + else (entry.get("dialogue") if isinstance(entry.get("dialogue"), list) else [line for line in str(entry.get("content", "")).splitlines() if line.strip()]) for entry in hidden_entries_by_type(entries, "dialogue") ], "journals": hidden_contents(entries, "observation"), diff --git a/src/authoring_service/templates.py b/src/authoring_service/templates.py index 819dcd0..30f746a 100755 --- a/src/authoring_service/templates.py +++ b/src/authoring_service/templates.py @@ -1,3697 +1,1129 @@ -"""HTML templates served by the local authoring UI.""" - -from __future__ import annotations - -APP_HTML = r""" - -
- - - -Beats play in order for your game prototype.
+ + +Tooltips, search routes, and ambient lines for org_web. Not part of the game timeline unless you add them as memory beats.
+ +