This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
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 .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("/"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user