This commit is contained in:
@@ -3,87 +3,35 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
HIDDEN_CONTENT_TYPES = [
|
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",
|
"quote",
|
||||||
"poem",
|
"poem",
|
||||||
"story",
|
"hidden dialogue",
|
||||||
"observatory",
|
"journal entry",
|
||||||
"hidden route",
|
"rare event",
|
||||||
"search",
|
"loading screen message",
|
||||||
"keyboard",
|
"secret interaction",
|
||||||
"play",
|
"hidden tooltip",
|
||||||
"dream",
|
"future z message",
|
||||||
"temporal",
|
"young z memory fragment",
|
||||||
"seasonal",
|
"sensei chi wisdom entry",
|
||||||
"loading",
|
"aphy system message",
|
||||||
"guestbook",
|
"lima note/message",
|
||||||
"terminal",
|
"dream sequence",
|
||||||
"layer guide",
|
"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 = {
|
CHARACTER_REGISTRY = {
|
||||||
"young z": {
|
"young z": {
|
||||||
"id": "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_RARITIES = ["common", "uncommon", "rare", "very rare", "seasonal", "timed"]
|
||||||
HIDDEN_STORY_MARKERS = ["public", "hidden", "rare", "emotional", "dream-like", "temporal"]
|
HIDDEN_STORY_MARKERS = ["public", "hidden", "rare", "emotional", "dream-like", "temporal"]
|
||||||
HIDDEN_DISCOVERY_STYLES = ["gradual", "direct", "hidden route", "character-led", "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 = [
|
HIDDEN_LAYER_DEPTHS = [
|
||||||
{
|
{
|
||||||
"id": "0",
|
"id": "0",
|
||||||
|
|||||||
@@ -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()
|
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["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 ""))
|
entry["canonicalUse"] = str(normalize_character_text_refs(entry.get("canonicalUse") or ""))
|
||||||
for key in ["dialogue", "keyboard"]:
|
if entry.get("dialogue") is not None or content_type == "dialogue":
|
||||||
if key in entry:
|
entry["dialogue"] = normalize_dialogue(entry.get("dialogue") or entry.get("content"))
|
||||||
entry[key] = normalize_character_text_refs(entry[key])
|
if "keyboard" in entry:
|
||||||
|
entry["keyboard"] = normalize_character_text_refs(entry["keyboard"])
|
||||||
return entry
|
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]:
|
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()
|
today = datetime.now().date().isoformat()
|
||||||
story = existing.copy() if existing else {}
|
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()
|
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):
|
if node_id and node_id not in nodes and (not ids or node_id in ids):
|
||||||
nodes.append(node_id)
|
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", []))
|
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()]
|
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()]
|
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}"))
|
story_id = slugify(str(story.get("id") or f"story-{title}"))
|
||||||
if not story_id.startswith("story-"):
|
if not story_id.startswith("story-"):
|
||||||
story_id = f"story-{story_id}"
|
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,
|
"id": story_id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"description": str(normalize_character_text_refs(story.get("description") or "")),
|
"description": description,
|
||||||
"tone": str(story.get("tone") or "warm"),
|
"tone": str(story.get("tone") or "warm"),
|
||||||
"characters": characters,
|
"characters": characters,
|
||||||
"symbols": symbols,
|
"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()],
|
"unlockConditions": [str(normalize_character_text_refs(item)).strip() for item in story.get("unlockConditions", []) if str(item).strip()],
|
||||||
"hidden": bool(story.get("hidden", False)),
|
"hidden": bool(story.get("hidden", False)),
|
||||||
"markers": markers,
|
"markers": markers,
|
||||||
|
"migrated": migrated,
|
||||||
|
"game": game,
|
||||||
"createdDate": str(story.get("createdDate") or today),
|
"createdDate": str(story.get("createdDate") or today),
|
||||||
"modifiedDate": 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]]:
|
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": [],
|
"unlockConditions": [],
|
||||||
"hidden": False,
|
"hidden": False,
|
||||||
"markers": ["emotional"],
|
"markers": ["emotional"],
|
||||||
|
"migrated": True,
|
||||||
|
"items": [],
|
||||||
|
"game": {"targetEngine": "generic-2d", "estimatedMinutes": 5, "tags": []},
|
||||||
}
|
}
|
||||||
story = story_groups[key]
|
story = story_groups[key]
|
||||||
if entry["id"] not in story["nodes"]:
|
if entry["id"] not in story["nodes"]:
|
||||||
story["nodes"].append(entry["id"])
|
story["nodes"].append(entry["id"])
|
||||||
|
story["items"].append({"kind": "memory", "id": entry["id"]})
|
||||||
for character in entry.get("characters", []):
|
for character in entry.get("characters", []):
|
||||||
if character not in story["characters"]:
|
if character not in story["characters"]:
|
||||||
story["characters"].append(character)
|
story["characters"].append(character)
|
||||||
@@ -508,10 +626,101 @@ def load_hidden_store() -> dict[str, Any]:
|
|||||||
"layers": HIDDEN_LAYER_DEPTHS,
|
"layers": HIDDEN_LAYER_DEPTHS,
|
||||||
"entries": normalized,
|
"entries": normalized,
|
||||||
"stories": normalized_stories,
|
"stories": normalized_stories,
|
||||||
|
"migrationHints": hidden_migration_hints(normalized, normalized_stories),
|
||||||
"recommendations": hidden_architecture_recommendations(),
|
"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]:
|
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}
|
ids = {entry["id"] for entry in entries}
|
||||||
unknown_characters = []
|
unknown_characters = []
|
||||||
@@ -589,7 +798,9 @@ def generated_hidden_content_block(entries: list[dict[str, Any]]) -> str:
|
|||||||
lore = {
|
lore = {
|
||||||
"quotes": quote_like,
|
"quotes": quote_like,
|
||||||
"conversations": [
|
"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")
|
for entry in hidden_entries_by_type(entries, "dialogue")
|
||||||
],
|
],
|
||||||
"journals": hidden_contents(entries, "observation"),
|
"journals": hidden_contents(entries, "observation"),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
766
src/authoring_service/templates_hidden.py
Normal file
766
src/authoring_service/templates_hidden.py
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
"""Story Studio UI for hidden narrative authoring."""
|
||||||
|
|
||||||
|
HIDDEN_APP_HTML = r"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Story Studio</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #12100d;
|
||||||
|
--ink: #f8ead1;
|
||||||
|
--muted: #b9a98e;
|
||||||
|
--line: rgba(232, 202, 139, 0.24);
|
||||||
|
--gold: #d3a64d;
|
||||||
|
--rose: #d06b78;
|
||||||
|
--panel: rgba(19, 16, 13, 0.92);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: linear-gradient(145deg, #0f0d0b, #241b17);
|
||||||
|
}
|
||||||
|
button, input, textarea, select { font: inherit; }
|
||||||
|
button {
|
||||||
|
min-height: 34px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(251, 241, 220, 0.08);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.primary { background: var(--gold); color: #1a120b; font-weight: 700; border-color: #edc778; }
|
||||||
|
button.danger { border-color: rgba(208, 107, 120, 0.6); color: #ffd8dd; }
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(92, 67, 34, 0.34);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fbf1dc;
|
||||||
|
color: #32251a;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
textarea { min-height: 90px; resize: vertical; line-height: 1.5; }
|
||||||
|
label { display: grid; gap: 4px; font-size: 12px; font-weight: 700; color: #d9c28d; }
|
||||||
|
h1, h2 { font-family: Georgia, serif; margin: 0; }
|
||||||
|
h1 { font-size: 22px; }
|
||||||
|
h2 { font-size: 16px; margin-bottom: 8px; }
|
||||||
|
.studio {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr) 380px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.col {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.left { border-right: 1px solid var(--line); padding: 16px; overflow: auto; }
|
||||||
|
.center { padding: 16px; overflow: auto; }
|
||||||
|
.right { border-left: 1px solid var(--line); padding: 16px; overflow: auto; }
|
||||||
|
.subtle { color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||||
|
.status {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-left: 4px solid var(--gold);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||||
|
.tabs { display: flex; gap: 6px; margin-bottom: 12px; }
|
||||||
|
.tabs button.active { border-color: var(--gold); background: rgba(211, 166, 77, 0.2); }
|
||||||
|
.story-list { display: grid; gap: 8px; margin-top: 10px; }
|
||||||
|
.story-card {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(251, 241, 220, 0.05);
|
||||||
|
}
|
||||||
|
.story-card.active { border-color: var(--gold); background: rgba(211, 166, 77, 0.16); }
|
||||||
|
.story-card small { display: block; color: var(--muted); margin-top: 4px; }
|
||||||
|
.timeline { display: grid; gap: 10px; margin-top: 12px; }
|
||||||
|
.beat {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(251, 241, 220, 0.05);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.beat.active { border-color: var(--gold); }
|
||||||
|
.beat-index {
|
||||||
|
width: 32px; height: 32px; border-radius: 50%;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: rgba(211, 166, 77, 0.18);
|
||||||
|
border: 1px solid rgba(211, 166, 77, 0.5);
|
||||||
|
font-weight: 800; font-size: 12px;
|
||||||
|
}
|
||||||
|
.beat-kind { font-size: 11px; color: #efd9a6; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.beat-title { font-weight: 700; margin-top: 2px; }
|
||||||
|
.beat-preview { color: var(--muted); font-size: 13px; margin-top: 4px; }
|
||||||
|
.panel { margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
|
||||||
|
.grid { display: grid; gap: 10px; }
|
||||||
|
.grid.two { grid-template-columns: 1fr 1fr; }
|
||||||
|
.full { grid-column: 1 / -1; }
|
||||||
|
.dialogue-lines { display: grid; gap: 8px; }
|
||||||
|
.dialogue-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.fragment-list { display: grid; gap: 8px; max-height: 52vh; overflow: auto; }
|
||||||
|
.fragment-item {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(251, 241, 220, 0.04);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fragment-item:hover { border-color: var(--gold); }
|
||||||
|
.preview-box {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
.preview-line { margin: 8px 0; }
|
||||||
|
.preview-line strong { color: var(--gold); }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-size: 11px;
|
||||||
|
color: #efd9a6;
|
||||||
|
}
|
||||||
|
.migrated { opacity: 0.72; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="studio">
|
||||||
|
<aside class="left col">
|
||||||
|
<h1>Story Studio</h1>
|
||||||
|
<p class="subtle">Write emotional routes for 2D games. Website fragments live in the library tab.</p>
|
||||||
|
<div id="status" class="status">Loading…</div>
|
||||||
|
<div class="tabs">
|
||||||
|
<button type="button" id="tabStudio" class="active">Stories</button>
|
||||||
|
<button type="button" id="tabFragments">Fragment library</button>
|
||||||
|
</div>
|
||||||
|
<div id="studioNav">
|
||||||
|
<label><input type="checkbox" id="showMigrated" /> Show auto-migrated stories</label>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" id="newStoryBtn">New story</button>
|
||||||
|
<button type="button" id="saveBtn" class="primary">Save</button>
|
||||||
|
</div>
|
||||||
|
<div id="storyList" class="story-list"></div>
|
||||||
|
</div>
|
||||||
|
<div id="fragmentNav" class="hidden">
|
||||||
|
<input id="fragmentSearch" type="search" placeholder="Search fragments…" />
|
||||||
|
<div class="grid two" style="margin-top:8px">
|
||||||
|
<label>Class<select id="fragmentClass"><option value="">All</option></select></label>
|
||||||
|
<label>Surface<select id="fragmentSurface"><option value="">All</option></select></label>
|
||||||
|
</div>
|
||||||
|
<div id="fragmentList" class="fragment-list" style="margin-top:10px"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="center col">
|
||||||
|
<div id="studioMain">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" id="addMemoryBeat">+ Memory beat</button>
|
||||||
|
<button type="button" id="addSectionBeat">+ Written section</button>
|
||||||
|
<button type="button" id="exportStoryBtn">Export game JSON</button>
|
||||||
|
<button type="button" id="previewBtn">Play preview</button>
|
||||||
|
</div>
|
||||||
|
<h2 id="storyHeading">Select a story</h2>
|
||||||
|
<p id="storySub" class="subtle">Beats play in order for your game prototype.</p>
|
||||||
|
<div id="timeline" class="timeline"></div>
|
||||||
|
<div id="previewBox" class="preview-box hidden"></div>
|
||||||
|
</div>
|
||||||
|
<div id="fragmentMain" class="hidden">
|
||||||
|
<h2>Website fragment</h2>
|
||||||
|
<p class="subtle">Tooltips, search routes, and ambient lines for org_web. Not part of the game timeline unless you add them as memory beats.</p>
|
||||||
|
<div id="fragmentEditor" class="grid"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="right col">
|
||||||
|
<div id="beatEditor" class="panel">
|
||||||
|
<h2>Beat editor</h2>
|
||||||
|
<p id="beatEditorSub" class="subtle">Select a beat in the timeline.</p>
|
||||||
|
<div id="beatFields" class="grid hidden">
|
||||||
|
<label class="full">Title<input id="beatTitle" /></label>
|
||||||
|
<label class="full">Location<input id="beatLocation" placeholder="kitchen, train, memory lane" /></label>
|
||||||
|
<label class="full">Notes<textarea id="beatNotes"></textarea></label>
|
||||||
|
<div class="full">
|
||||||
|
<div class="toolbar" style="margin:0 0 8px">
|
||||||
|
<strong>Dialogue</strong>
|
||||||
|
<button type="button" id="addLineBtn">+ Line</button>
|
||||||
|
</div>
|
||||||
|
<div id="dialogueLines" class="dialogue-lines"></div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar full">
|
||||||
|
<button type="button" id="moveUpBtn">Move up</button>
|
||||||
|
<button type="button" id="moveDownBtn">Move down</button>
|
||||||
|
<button type="button" id="removeBeatBtn" class="danger">Remove beat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="storyMeta" class="panel hidden">
|
||||||
|
<h2>Story details</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<label class="full">Title<input id="storyTitle" /></label>
|
||||||
|
<label class="full">Summary<textarea id="storyDescription"></textarea></label>
|
||||||
|
<label>Tone<select id="storyTone"></select></label>
|
||||||
|
<label>Discovery<select id="storyDiscovery"></select></label>
|
||||||
|
<label class="full">Characters<input id="storyCharacters" placeholder="lima, z" /></label>
|
||||||
|
<label>Est. minutes<input id="storyMinutes" type="number" min="1" max="120" /></label>
|
||||||
|
<label class="full">Game tags<input id="storyGameTags" placeholder="romance, domestic" /></label>
|
||||||
|
<label><input type="checkbox" id="storyHidden" /> Hidden on website</label>
|
||||||
|
<label><input type="checkbox" id="clearMigrated" /> Mark as authored (not migrated)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const state = {
|
||||||
|
entries: [],
|
||||||
|
stories: [],
|
||||||
|
meta: {},
|
||||||
|
selectedStoryId: "",
|
||||||
|
selectedBeatIndex: -1,
|
||||||
|
selectedEntryId: "",
|
||||||
|
tab: "studio",
|
||||||
|
showMigrated: false,
|
||||||
|
previewIndex: 0,
|
||||||
|
};
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || "untitled").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "untitled";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, ok = true) {
|
||||||
|
$("status").textContent = text;
|
||||||
|
$("status").style.borderLeftColor = ok ? "var(--gold)" : "var(--rose)";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, options);
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || response.statusText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedStory() {
|
||||||
|
return state.stories.find((story) => story.id === state.selectedStoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryById(id) {
|
||||||
|
return state.entries.find((entry) => entry.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStoryItems(story) {
|
||||||
|
if (!Array.isArray(story.items) || !story.items.length) {
|
||||||
|
story.items = (story.nodes || []).map((id) => ({ kind: "memory", id }));
|
||||||
|
}
|
||||||
|
if (!story.game) story.game = { targetEngine: "generic-2d", estimatedMinutes: 5, tags: [] };
|
||||||
|
return story;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beatPreview(item, index) {
|
||||||
|
const story = selectedStory();
|
||||||
|
if (!story) return { title: "", preview: "", kind: "memory" };
|
||||||
|
if (item.kind === "section") {
|
||||||
|
const text = String(item.content || "").trim();
|
||||||
|
return { title: item.title || `Section ${index + 1}`, preview: text.slice(0, 120), kind: "section" };
|
||||||
|
}
|
||||||
|
const entry = entryById(item.id);
|
||||||
|
const lines = item.dialogue || (story.beats && story.beats[index] && story.beats[index].dialogue) || (entry && entry.dialogue) || [];
|
||||||
|
const preview = lines.length ? lines.map((line) => `${line.speaker}: ${line.text}`).join(" · ") : (entry && entry.content) || "";
|
||||||
|
return { title: (entry && entry.title) || item.id, preview: String(preview).slice(0, 140), kind: "memory" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStoryList() {
|
||||||
|
const list = $("storyList");
|
||||||
|
list.innerHTML = "";
|
||||||
|
const stories = state.stories.filter((story) => state.showMigrated || !story.migrated);
|
||||||
|
if (!stories.length) {
|
||||||
|
list.innerHTML = '<p class="subtle">No stories yet. Create one to begin.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stories.forEach((story) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "story-card" + (story.id === state.selectedStoryId ? " active" : "") + (story.migrated ? " migrated" : "");
|
||||||
|
const beatCount = (story.items || []).length;
|
||||||
|
btn.innerHTML = `<strong>${story.title}</strong><small>${beatCount} beat${beatCount === 1 ? "" : "s"}${story.migrated ? " · migrated" : ""}</small>`;
|
||||||
|
btn.onclick = () => selectStory(story.id);
|
||||||
|
list.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline() {
|
||||||
|
const story = selectedStory();
|
||||||
|
const timeline = $("timeline");
|
||||||
|
timeline.innerHTML = "";
|
||||||
|
if (!story) {
|
||||||
|
$("storyHeading").textContent = "Select a story";
|
||||||
|
$("storySub").textContent = "Beats play in order for your game prototype.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureStoryItems(story);
|
||||||
|
$("storyHeading").textContent = story.title;
|
||||||
|
$("storySub").textContent = story.description || "Drag beats with Move up/down. Memory beats reference the fragment library.";
|
||||||
|
story.items.forEach((item, index) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "beat" + (index === state.selectedBeatIndex ? " active" : "");
|
||||||
|
const info = beatPreview(item, index);
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="beat-index">${index + 1}</div>
|
||||||
|
<div>
|
||||||
|
<div class="beat-kind">${info.kind}</div>
|
||||||
|
<div class="beat-title">${info.title}</div>
|
||||||
|
<div class="beat-preview">${info.preview || "(empty)"}</div>
|
||||||
|
</div>
|
||||||
|
<div><span class="pill">${item.kind === "section" ? "written" : "memory"}</span></div>`;
|
||||||
|
card.onclick = () => selectBeat(index);
|
||||||
|
timeline.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBeatEditor() {
|
||||||
|
const story = selectedStory();
|
||||||
|
const fields = $("beatFields");
|
||||||
|
if (!story || state.selectedBeatIndex < 0) {
|
||||||
|
fields.classList.add("hidden");
|
||||||
|
$("beatEditorSub").textContent = "Select a beat in the timeline.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = story.items[state.selectedBeatIndex];
|
||||||
|
fields.classList.remove("hidden");
|
||||||
|
$("beatEditorSub").textContent = item.kind === "section" ? "Written section — dialogue is authored inline." : "Memory beat — pulls from a library entry; dialogue can be overridden here.";
|
||||||
|
const beat = (story.beats && story.beats[state.selectedBeatIndex]) || {};
|
||||||
|
const entry = item.kind === "memory" ? entryById(item.id) : null;
|
||||||
|
$("beatTitle").value = item.title || beat.title || (item.kind === "section" ? item.title : (entry || {}).title) || "";
|
||||||
|
$("beatLocation").value = item.location || beat.location || "";
|
||||||
|
$("beatNotes").value = item.notes || beat.notes || "";
|
||||||
|
const lines = item.dialogue || beat.dialogue || (item.kind === "section" ? normalizeDialogue(item.content) : (entry || {}).dialogue) || [];
|
||||||
|
renderDialogueLines(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDialogue(content) {
|
||||||
|
if (Array.isArray(content) && content.length && typeof content[0] === "object") return content;
|
||||||
|
const lines = [];
|
||||||
|
String(content || "").split(/\n+/).forEach((raw) => {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const parts = text.split(":");
|
||||||
|
if (parts.length > 1) {
|
||||||
|
lines.push({ speaker: parts[0].trim(), text: parts.slice(1).join(":").trim() });
|
||||||
|
} else {
|
||||||
|
lines.push({ speaker: "z", text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDialogueLines(lines) {
|
||||||
|
const container = $("dialogueLines");
|
||||||
|
container.innerHTML = "";
|
||||||
|
const characters = state.meta.characters || [];
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "dialogue-line";
|
||||||
|
const speaker = document.createElement("select");
|
||||||
|
characters.forEach((name) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = name;
|
||||||
|
opt.textContent = name;
|
||||||
|
if (name === line.speaker) opt.selected = true;
|
||||||
|
speaker.appendChild(opt);
|
||||||
|
});
|
||||||
|
const text = document.createElement("textarea");
|
||||||
|
text.rows = 2;
|
||||||
|
text.value = line.text || "";
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.type = "button";
|
||||||
|
remove.className = "danger";
|
||||||
|
remove.textContent = "×";
|
||||||
|
remove.onclick = () => {
|
||||||
|
lines.splice(index, 1);
|
||||||
|
renderDialogueLines(lines);
|
||||||
|
};
|
||||||
|
row.append(speaker, text, remove);
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
container.dataset.lines = JSON.stringify(lines);
|
||||||
|
container._lines = lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDialogueLines() {
|
||||||
|
const container = $("dialogueLines");
|
||||||
|
const lines = [];
|
||||||
|
container.querySelectorAll(".dialogue-line").forEach((row) => {
|
||||||
|
const speaker = row.querySelector("select").value;
|
||||||
|
const text = row.querySelector("textarea").value.trim();
|
||||||
|
if (text) lines.push({ speaker, text });
|
||||||
|
});
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStoryMeta() {
|
||||||
|
const story = selectedStory();
|
||||||
|
$("storyMeta").classList.toggle("hidden", !story);
|
||||||
|
if (!story) return;
|
||||||
|
$("storyTitle").value = story.title || "";
|
||||||
|
$("storyDescription").value = story.description || "";
|
||||||
|
$("storyTone").value = story.tone || "warm";
|
||||||
|
$("storyDiscovery").value = story.discoveryStyle || "gradual";
|
||||||
|
$("storyCharacters").value = (story.characters || []).join(", ");
|
||||||
|
$("storyMinutes").value = (story.game && story.game.estimatedMinutes) || 5;
|
||||||
|
$("storyGameTags").value = ((story.game && story.game.tags) || []).join(", ");
|
||||||
|
$("storyHidden").checked = !!story.hidden;
|
||||||
|
$("clearMigrated").checked = !story.migrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBeatEdits() {
|
||||||
|
const story = selectedStory();
|
||||||
|
if (!story || state.selectedBeatIndex < 0) return;
|
||||||
|
const item = story.items[state.selectedBeatIndex];
|
||||||
|
const dialogue = readDialogueLines();
|
||||||
|
const title = $("beatTitle").value.trim();
|
||||||
|
const location = $("beatLocation").value.trim();
|
||||||
|
const notes = $("beatNotes").value.trim();
|
||||||
|
if (item.kind === "section") {
|
||||||
|
item.title = title || item.title;
|
||||||
|
item.content = dialogue.map((line) => `${line.speaker}: ${line.text}`).join("\n");
|
||||||
|
item.tone = story.tone;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (title) item.title = title;
|
||||||
|
item.location = location;
|
||||||
|
item.notes = notes;
|
||||||
|
if (dialogue.length) item.dialogue = dialogue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFragmentList() {
|
||||||
|
const query = $("fragmentSearch").value.trim().toLowerCase();
|
||||||
|
const cls = $("fragmentClass").value;
|
||||||
|
const surface = $("fragmentSurface").value;
|
||||||
|
const list = $("fragmentList");
|
||||||
|
list.innerHTML = "";
|
||||||
|
state.entries.filter((entry) => {
|
||||||
|
if (cls && entry.contentClass !== cls) return false;
|
||||||
|
if (surface && !(entry.surfaces || []).includes(surface)) return false;
|
||||||
|
if (query) {
|
||||||
|
const hay = `${entry.title} ${entry.content} ${(entry.tags || []).join(" ")}`.toLowerCase();
|
||||||
|
if (!hay.includes(query)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).slice(0, 80).forEach((entry) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "fragment-item";
|
||||||
|
item.innerHTML = `<strong>${entry.title}</strong><div class="subtle">${entry.contentClass} · ${(entry.surfaces || []).join(", ")}</div><div class="beat-preview">${entry.content.slice(0, 100)}</div>`;
|
||||||
|
item.onclick = () => selectFragment(entry.id);
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFragmentEditor() {
|
||||||
|
const entry = entryById(state.selectedEntryId);
|
||||||
|
const editor = $("fragmentEditor");
|
||||||
|
editor.innerHTML = "";
|
||||||
|
if (!entry) {
|
||||||
|
editor.innerHTML = '<p class="subtle">Select a fragment from the library.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fields = [
|
||||||
|
["title", "Title", entry.title],
|
||||||
|
["type", "Delivery type", entry.type],
|
||||||
|
["contentClass", "Content class", entry.contentClass],
|
||||||
|
["content", "Text", entry.content, true],
|
||||||
|
["triggerConditions", "Trigger", entry.triggerConditions || ""],
|
||||||
|
["pageLocation", "Page location", entry.pageLocation || ""],
|
||||||
|
];
|
||||||
|
fields.forEach(([key, label, value, area]) => {
|
||||||
|
const wrap = document.createElement("label");
|
||||||
|
wrap.className = "full";
|
||||||
|
wrap.textContent = label;
|
||||||
|
const input = area ? document.createElement("textarea") : document.createElement("input");
|
||||||
|
input.value = value || "";
|
||||||
|
input.dataset.field = key;
|
||||||
|
wrap.appendChild(input);
|
||||||
|
editor.appendChild(wrap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStory(id) {
|
||||||
|
applyBeatEdits();
|
||||||
|
state.selectedStoryId = id;
|
||||||
|
state.selectedBeatIndex = -1;
|
||||||
|
renderStoryList();
|
||||||
|
renderTimeline();
|
||||||
|
renderBeatEditor();
|
||||||
|
renderStoryMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBeat(index) {
|
||||||
|
applyBeatEdits();
|
||||||
|
state.selectedBeatIndex = index;
|
||||||
|
renderTimeline();
|
||||||
|
renderBeatEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFragment(id) {
|
||||||
|
state.selectedEntryId = id;
|
||||||
|
renderFragmentList();
|
||||||
|
renderFragmentEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function newStory() {
|
||||||
|
const id = `story-${slugify("untitled")}-${Date.now()}`;
|
||||||
|
const story = {
|
||||||
|
id,
|
||||||
|
title: "Untitled story",
|
||||||
|
description: "",
|
||||||
|
tone: "warm",
|
||||||
|
characters: [],
|
||||||
|
symbols: [],
|
||||||
|
items: [],
|
||||||
|
nodes: [],
|
||||||
|
discoveryStyle: "gradual",
|
||||||
|
layerAffinity: [],
|
||||||
|
unlockConditions: [],
|
||||||
|
hidden: false,
|
||||||
|
markers: ["emotional"],
|
||||||
|
migrated: false,
|
||||||
|
game: { targetEngine: "generic-2d", estimatedMinutes: 5, tags: [] },
|
||||||
|
};
|
||||||
|
state.stories.unshift(story);
|
||||||
|
selectStory(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMemoryBeat(entryId = "") {
|
||||||
|
const story = selectedStory();
|
||||||
|
if (!story) return newStory(), addMemoryBeat(entryId);
|
||||||
|
if (!entryId) {
|
||||||
|
const first = state.entries.find((e) => e.contentClass === "memory" || e.contentClass === "story material");
|
||||||
|
entryId = first ? first.id : "";
|
||||||
|
}
|
||||||
|
if (!entryId) {
|
||||||
|
setStatus("Create a fragment first, or pick one from the library.", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
story.items.push({ kind: "memory", id: entryId });
|
||||||
|
story.nodes = story.items.filter((i) => i.kind === "memory").map((i) => i.id);
|
||||||
|
selectBeat(story.items.length - 1);
|
||||||
|
renderTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSectionBeat() {
|
||||||
|
const story = selectedStory();
|
||||||
|
if (!story) return;
|
||||||
|
story.items.push({
|
||||||
|
kind: "section",
|
||||||
|
id: `section-${Date.now()}`,
|
||||||
|
title: "New section",
|
||||||
|
content: "",
|
||||||
|
tone: story.tone || "warm",
|
||||||
|
});
|
||||||
|
selectBeat(story.items.length - 1);
|
||||||
|
renderTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBeat() {
|
||||||
|
const story = selectedStory();
|
||||||
|
if (!story || state.selectedBeatIndex < 0) return;
|
||||||
|
story.items.splice(state.selectedBeatIndex, 1);
|
||||||
|
story.nodes = story.items.filter((i) => i.kind === "memory").map((i) => i.id);
|
||||||
|
state.selectedBeatIndex = -1;
|
||||||
|
renderTimeline();
|
||||||
|
renderBeatEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveBeat(delta) {
|
||||||
|
const story = selectedStory();
|
||||||
|
if (!story || state.selectedBeatIndex < 0) return;
|
||||||
|
const next = state.selectedBeatIndex + delta;
|
||||||
|
if (next < 0 || next >= story.items.length) return;
|
||||||
|
const [item] = story.items.splice(state.selectedBeatIndex, 1);
|
||||||
|
story.items.splice(next, 0, item);
|
||||||
|
state.selectedBeatIndex = next;
|
||||||
|
renderTimeline();
|
||||||
|
renderBeatEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function storyFromForm() {
|
||||||
|
const story = selectedStory();
|
||||||
|
if (!story) return null;
|
||||||
|
applyBeatEdits();
|
||||||
|
story.title = $("storyTitle").value.trim() || story.title;
|
||||||
|
story.description = $("storyDescription").value;
|
||||||
|
story.tone = $("storyTone").value;
|
||||||
|
story.discoveryStyle = $("storyDiscovery").value;
|
||||||
|
story.characters = $("storyCharacters").value.split(/[,/]+/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
story.hidden = $("storyHidden").checked;
|
||||||
|
story.migrated = !$("clearMigrated").checked;
|
||||||
|
story.game = {
|
||||||
|
targetEngine: "generic-2d",
|
||||||
|
estimatedMinutes: Number($("storyMinutes").value) || 5,
|
||||||
|
tags: $("storyGameTags").value.split(/[,/]+/).map((s) => s.trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
story.nodes = story.items.filter((i) => i.kind === "memory").map((i) => i.id);
|
||||||
|
return story;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFragmentEdits() {
|
||||||
|
const entry = entryById(state.selectedEntryId);
|
||||||
|
if (!entry) return;
|
||||||
|
$("fragmentEditor").querySelectorAll("[data-field]").forEach((input) => {
|
||||||
|
entry[input.dataset.field] = input.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
storyFromForm();
|
||||||
|
applyFragmentEdits();
|
||||||
|
setStatus("Saving…");
|
||||||
|
const payload = await api("/api/hidden", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ entries: state.entries, stories: state.stories }),
|
||||||
|
});
|
||||||
|
hydrate(payload);
|
||||||
|
setStatus(payload.message || "Saved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportGame() {
|
||||||
|
const story = selectedStory();
|
||||||
|
if (!story) return;
|
||||||
|
const query = `?story=${encodeURIComponent(story.id)}`;
|
||||||
|
const data = await api(`/api/hidden/export/game${query}`);
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${story.id}-game.json`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setStatus(`Exported ${data.stories.length} story for game prototype.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playPreview() {
|
||||||
|
const story = selectedStory();
|
||||||
|
if (!story) return;
|
||||||
|
const beats = story.beats || [];
|
||||||
|
const box = $("previewBox");
|
||||||
|
box.classList.remove("hidden");
|
||||||
|
if (!beats.length) {
|
||||||
|
box.innerHTML = "<p class='subtle'>No beats yet.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.previewIndex >= beats.length) state.previewIndex = 0;
|
||||||
|
const beat = beats[state.previewIndex];
|
||||||
|
const lines = (beat.dialogue || []).map((line) => `<div class="preview-line"><strong>${line.speaker}</strong> ${line.text}</div>`).join("");
|
||||||
|
box.innerHTML = `<h3>${beat.title || "Beat"}</h3>${lines || "<p class='subtle'>No dialogue</p>"}<div class="toolbar" style="margin-top:12px"><button type="button" id="prevPreview">Previous</button><button type="button" id="nextPreview">Next</button></div>`;
|
||||||
|
$("prevPreview").onclick = () => { state.previewIndex = Math.max(0, state.previewIndex - 1); playPreview(); };
|
||||||
|
$("nextPreview").onclick = () => { state.previewIndex = Math.min(beats.length - 1, state.previewIndex + 1); playPreview(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrate(data) {
|
||||||
|
state.entries = data.entries || [];
|
||||||
|
state.stories = data.stories || [];
|
||||||
|
state.meta = data;
|
||||||
|
if (!state.selectedStoryId && state.stories.length) {
|
||||||
|
const first = state.stories.find((s) => !s.migrated) || state.stories[0];
|
||||||
|
state.selectedStoryId = first.id;
|
||||||
|
}
|
||||||
|
fillSelect($("storyTone"), data.tones || []);
|
||||||
|
fillSelect($("storyDiscovery"), data.discoveryStyles || []);
|
||||||
|
fillSelect($("fragmentClass"), data.contentClasses || []);
|
||||||
|
fillSelect($("fragmentSurface"), data.surfaces || []);
|
||||||
|
renderStoryList();
|
||||||
|
renderTimeline();
|
||||||
|
renderBeatEditor();
|
||||||
|
renderStoryMeta();
|
||||||
|
renderFragmentList();
|
||||||
|
renderFragmentEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(select, options) {
|
||||||
|
const current = select.value;
|
||||||
|
const keep = select.id === "fragmentClass" || select.id === "fragmentSurface";
|
||||||
|
if (!keep) select.innerHTML = "";
|
||||||
|
else while (select.options.length > 1) select.remove(1);
|
||||||
|
options.forEach((value) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = value;
|
||||||
|
opt.textContent = value;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (current) select.value = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTab(tab) {
|
||||||
|
state.tab = tab;
|
||||||
|
$("tabStudio").classList.toggle("active", tab === "studio");
|
||||||
|
$("tabFragments").classList.toggle("active", tab === "fragments");
|
||||||
|
$("studioNav").classList.toggle("hidden", tab !== "studio");
|
||||||
|
$("fragmentNav").classList.toggle("hidden", tab !== "fragments");
|
||||||
|
$("studioMain").classList.toggle("hidden", tab !== "studio");
|
||||||
|
$("fragmentMain").classList.toggle("hidden", tab !== "fragments");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/hidden");
|
||||||
|
hydrate(data);
|
||||||
|
const hints = data.migrationHints || {};
|
||||||
|
setStatus(`Loaded ${data.stories.length} stories · ${hints.summary?.migratedStoryCount || 0} migrated`);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$("tabStudio").onclick = () => setTab("studio");
|
||||||
|
$("tabFragments").onclick = () => setTab("fragments");
|
||||||
|
$("showMigrated").onchange = (event) => { state.showMigrated = event.target.checked; renderStoryList(); };
|
||||||
|
$("newStoryBtn").onclick = newStory;
|
||||||
|
$("saveBtn").onclick = save;
|
||||||
|
$("addMemoryBeat").onclick = () => addMemoryBeat(state.selectedEntryId);
|
||||||
|
$("addSectionBeat").onclick = addSectionBeat;
|
||||||
|
$("exportStoryBtn").onclick = exportGame;
|
||||||
|
$("previewBtn").onclick = playPreview;
|
||||||
|
$("addLineBtn").onclick = () => renderDialogueLines(readDialogueLines().concat([{ speaker: "z", text: "" }]));
|
||||||
|
$("removeBeatBtn").onclick = removeBeat;
|
||||||
|
$("moveUpBtn").onclick = () => moveBeat(-1);
|
||||||
|
$("moveDownBtn").onclick = () => moveBeat(1);
|
||||||
|
$("fragmentSearch").oninput = renderFragmentList;
|
||||||
|
$("fragmentClass").onchange = renderFragmentList;
|
||||||
|
$("fragmentSurface").onchange = renderFragmentList;
|
||||||
|
["storyTitle", "storyDescription", "storyTone", "storyDiscovery", "storyCharacters", "storyMinutes", "storyGameTags", "storyHidden", "clearMigrated"].forEach((id) => {
|
||||||
|
$(id).addEventListener("change", storyFromForm);
|
||||||
|
$(id).addEventListener("input", storyFromForm);
|
||||||
|
});
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
@@ -17,7 +17,7 @@ from urllib.parse import parse_qs, urlparse
|
|||||||
from .build import BUILD_QUEUE, queue_build, queue_hidden_build
|
from .build import BUILD_QUEUE, queue_build, queue_hidden_build
|
||||||
from .config import ROOT
|
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 .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
|
from .templates import APP_HTML, HIDDEN_APP_HTML
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
@@ -89,6 +89,30 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
return
|
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/"):
|
if parsed.path.startswith("/assets/avatars/"):
|
||||||
try:
|
try:
|
||||||
relative = Path(parsed.path.lstrip("/"))
|
relative = Path(parsed.path.lstrip("/"))
|
||||||
|
|||||||
@@ -367,5 +367,80 @@ class BuildQueueTests(unittest.TestCase):
|
|||||||
root.rmdir()
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user