cursor redesign
Some checks failed
Build Authoring Service / build (push) Failing after 8s

This commit is contained in:
2026-05-15 15:05:23 +01:00
parent 0cc588e9cc
commit 82b57d6d5c
6 changed files with 2260 additions and 3782 deletions

View File

@@ -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",

View File

@@ -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

View 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>
"""

View File

@@ -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("/"))

View File

@@ -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()