From 82b57d6d5c392bc470fca826271ec6342b923d1d Mon Sep 17 00:00:00 2001 From: Zaine Date: Fri, 15 May 2026 15:05:23 +0100 Subject: [PATCH] cursor redesign --- src/authoring_service/constants.py | 124 +- src/authoring_service/hidden.py | 225 +- src/authoring_service/templates.py | 4826 +++++---------------- src/authoring_service/templates_hidden.py | 766 ++++ src/authoring_service/web.py | 26 +- src/tests/test_authoring_server.py | 75 + 6 files changed, 2260 insertions(+), 3782 deletions(-) create mode 100644 src/authoring_service/templates_hidden.py 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""" - - - - - - Org Site Authoring - - - -
- -
-
-
-

New page

- -
-
- - - - - - - -
-
- - - - - - - - - - - - - -
- -
- - -
- -
- - - -
-
-

Build queue

-
-

Latest build log

-

-    
-
- - - -""" - - -HIDDEN_APP_HTML = r""" - - - - - Hidden Memory Observatory - - - -
- - -
-
-
-

Emotional Graph

-
Layer 0 lives near the edge. Layer 5 rests at the quiet core.
-
-
- - - - - - - - - - -
-
- -
- -
Click a story to open its route. Drag memories in the Story Builder to shape the path.
- -
Atlas
-
- - -
- - - - - -""" +"""HTML templates served by the local authoring UI.""" + +from __future__ import annotations + +APP_HTML = r""" + + + + + + Org Site Authoring + + + +
+ +
+
+
+

New page

+ +
+
+ + + + + + + +
+
+ + + + + + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+
+

Build queue

+
+

Latest build log

+

+    
+
+ + + +""" + +from .templates_hidden import HIDDEN_APP_HTML diff --git a/src/authoring_service/templates_hidden.py b/src/authoring_service/templates_hidden.py new file mode 100644 index 0000000..85dac02 --- /dev/null +++ b/src/authoring_service/templates_hidden.py @@ -0,0 +1,766 @@ +"""Story Studio UI for hidden narrative authoring.""" + +HIDDEN_APP_HTML = r""" + + + + + Story Studio + + + +
+ + +
+
+
+ + + + +
+

Select a story

+

Beats play in order for your game prototype.

+
+ +
+ +
+ + +
+ + + + +""" diff --git a/src/authoring_service/web.py b/src/authoring_service/web.py index 7b569f4..4f8e460 100755 --- a/src/authoring_service/web.py +++ b/src/authoring_service/web.py @@ -17,7 +17,7 @@ from urllib.parse import parse_qs, urlparse from .build import BUILD_QUEUE, queue_build, queue_hidden_build from .config import ROOT from .content import list_pages, page_to_dict, parse_upload_form, read_page, safe_relative_path, save_page, save_upload, server_diagnostics -from .hidden import load_hidden_store, save_hidden_store +from .hidden import export_game_stories, list_fragment_entries, load_hidden_store, save_hidden_store from .templates import APP_HTML, HIDDEN_APP_HTML class Handler(BaseHTTPRequestHandler): @@ -89,6 +89,30 @@ class Handler(BaseHTTPRequestHandler): except Exception as exc: self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) return + if parsed.path == "/api/hidden/export/game": + try: + query = parse_qs(parsed.query) + story_id = query.get("story", [""])[0].strip() or None + export_all = query.get("all", ["false"])[0].lower() in {"1", "true", "yes"} + store = load_hidden_store() + self.send_json(export_game_stories(store["stories"], store["entries"], story_id, export_all)) + except Exception as exc: + self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + return + if parsed.path == "/api/hidden/fragments": + try: + query = parse_qs(parsed.query) + store = load_hidden_store() + fragments = list_fragment_entries( + store["entries"], + content_class=query.get("contentClass", [""])[0], + surface=query.get("surface", [""])[0], + query=query.get("q", [""])[0], + ) + self.send_json({"fragments": fragments, "count": len(fragments)}) + except Exception as exc: + self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + return if parsed.path.startswith("/assets/avatars/"): try: relative = Path(parsed.path.lstrip("/")) diff --git a/src/tests/test_authoring_server.py b/src/tests/test_authoring_server.py index e8c5796..674a2ef 100755 --- a/src/tests/test_authoring_server.py +++ b/src/tests/test_authoring_server.py @@ -367,5 +367,80 @@ class BuildQueueTests(unittest.TestCase): root.rmdir() +class HiddenStudioTests(unittest.TestCase): + def test_normalize_dialogue_from_flat_array(self): + import authoring_service.hidden as hidden + + lines = hidden.normalize_dialogue(["lima", "Hello there", "z", "Hi."]) + self.assertEqual(lines[0]["speaker"], "lima") + self.assertEqual(lines[0]["text"], "Hello there") + + def test_story_items_to_beats_uses_memory_overrides(self): + import authoring_service.hidden as hidden + + entries = [ + hidden.normalize_hidden_entry({ + "id": "memory-test", + "type": "quote", + "title": "Original", + "content": "Original text", + }) + ] + story = hidden.normalize_hidden_story({ + "id": "story-test", + "title": "Test story", + "items": [{ + "kind": "memory", + "id": "memory-test", + "title": "Override title", + "dialogue": [{"speaker": "lima", "text": "Override line"}], + }], + }, entries) + self.assertEqual(story["beats"][0]["title"], "Override title") + self.assertEqual(story["beats"][0]["dialogue"][0]["text"], "Override line") + + def test_export_game_stories_filters_migrated_by_default(self): + import authoring_service.hidden as hidden + + entries = [ + hidden.normalize_hidden_entry({ + "id": "memory-a", + "type": "quote", + "title": "A", + "content": "Line", + }) + ] + stories = [ + hidden.normalize_hidden_story({ + "id": "story-authored", + "title": "Authored", + "migrated": False, + "items": [{"kind": "memory", "id": "memory-a"}], + }, entries), + hidden.normalize_hidden_story({ + "id": "story-migrated", + "title": "Migrated", + "migrated": True, + "items": [{"kind": "memory", "id": "memory-a"}], + }, entries), + ] + exported = hidden.export_game_stories(stories, entries) + self.assertEqual(len(exported["stories"]), 1) + self.assertEqual(exported["stories"][0]["id"], "story-authored") + + def test_infer_entry_metadata_maps_legacy_tooltip_type(self): + import authoring_service.hidden as hidden + + entry = hidden.normalize_hidden_entry({ + "id": "tip-1", + "type": "hidden tooltip", + "title": "Tip", + "content": "A tooltip", + }) + self.assertEqual(entry["type"], "tooltip") + self.assertEqual(entry["contentClass"], "fragment") + self.assertIn("tooltip", entry["surfaces"]) + + if __name__ == "__main__": unittest.main()